Files
prosperon/playdate.cm
2026-01-21 09:05:02 -06:00

436 lines
13 KiB
Plaintext

// ========================================================================
// PART 5: PLAYDATE BACKEND IMPLEMENTATION (Level 3)
// ========================================================================
// Same interface as SDL3, completely different implementation.
// Shows where features degrade gracefully.
function PlaydateBackend(pd) {
this.pd = pd // Playdate API pointer
this.target_pool = {}
this.current_target = null
this.current_camera = null
}
PlaydateBackend.prototype.get_capabilities = function() {
return {
supports_shaders: false, // NO SHADERS
supports_render_targets: true, // LCDBitmap*
supports_blend_modes: true, // Limited (copy, inverted, XOR, etc)
supports_stencil: false,
max_texture_size: 400, // Screen size basically
native_mask_support: true, // setMask() is native!
native_effects: ['dither', 'invert']
}
}
// ========================================================================
// RENDER TARGET MANAGEMENT
// ========================================================================
PlaydateBackend.prototype.get_or_create_target = function(width, height, key) {
var pool_key = `${width}x${height}`
if (!this.target_pool[pool_key])
this.target_pool[pool_key] = []
// Reuse from pool
arrfor(this.target_pool[pool_key], function(target) {
if (!target.in_use) {
target.in_use = true
// Clear bitmap for reuse
this.pd.graphics.pushContext(target.bitmap)
this.pd.graphics.clear(this.pd.graphics.kColorClear)
this.pd.graphics.popContext()
return target
}
})
// Create new LCDBitmap
var bitmap = this.pd.graphics.newBitmap(width, height, this.pd.graphics.kColorClear)
var target = {
bitmap: bitmap,
width: width,
height: height,
in_use: true,
key: key
}
push(this.target_pool[pool_key], target)
return target
}
PlaydateBackend.prototype.release_all_targets = function() {
arrfor(array(this.target_pool), function(key) {
arrfor(this.target_pool[key], function(target) {
target.in_use = false
})
})
}
// ========================================================================
// COMMAND EXECUTION
// ========================================================================
PlaydateBackend.prototype.execute = function(commands) {
// No command buffer concept - execute immediately
arrfor(commands, function(cmd) {
this.execute_command(cmd)
})
this.release_all_targets()
}
PlaydateBackend.prototype.execute_command = function(cmd) {
switch (cmd.cmd) {
case 'begin_render':
this.cmd_begin_render(cmd)
break
case 'end_render':
this.cmd_end_render()
break
case 'set_camera':
this.cmd_set_camera(cmd)
break
case 'draw_batch':
this.cmd_draw_batch(cmd)
break
case 'shader_pass':
this.cmd_shader_pass(cmd) // DEGRADES
break
case 'apply_mask':
this.cmd_apply_mask(cmd) // NATIVE!
break
case 'composite':
this.cmd_composite(cmd)
break
case 'blit':
this.cmd_blit(cmd)
break
case 'clear':
this.cmd_clear(cmd)
break
case 'present':
// Nothing to do, display updates automatically
break
default:
console.error(`Unknown command: ${cmd.cmd}`)
}
}
PlaydateBackend.prototype.cmd_begin_render = function(cmd) {
var target = cmd.target
var clear = cmd.clear
if (target == 'screen') {
// Render to screen framebuffer
this.current_target = 'screen'
// Playdate: screen is always the current context unless you push
} else {
// Render to bitmap
this.current_target = target
this.pd.graphics.pushContext(target.bitmap)
if (clear) {
// Clear with color (Playdate is 1-bit, so color becomes pattern/dither)
var pattern = this.color_to_dither_pattern(clear)
this.pd.graphics.setDitherPattern(pattern)
this.pd.graphics.fillRect(0, 0, target.width, target.height)
}
}
}
PlaydateBackend.prototype.cmd_end_render = function() {
if (this.current_target != 'screen') {
this.pd.graphics.popContext()
}
this.current_target = null
}
PlaydateBackend.prototype.cmd_set_camera = function(cmd) {
this.current_camera = cmd.camera
// Playdate doesn't have camera uniforms, we transform coordinates manually
}
PlaydateBackend.prototype.cmd_draw_batch = function(cmd) {
var geometry = cmd.geometry
var material = cmd.material || {}
// Get image (Playdate uses LCDBitmap for textures)
var image = this.get_image(material.texture || 'white')
if (!image) return
// Set draw mode based on material
var draw_mode = this.material_to_draw_mode(material)
this.pd.graphics.setImageDrawMode(draw_mode)
// Draw each sprite in the batch
for (var i = 0; i < length(geometry.indices); i += 6) {
// Each sprite is 2 triangles = 6 indices = 4 vertices
var vert_idx = geometry.indices[i]
var v = geometry.vertices[vert_idx]
// Transform by camera
var screen_pos = this.world_to_screen(v.pos, this.current_camera)
// Get sprite size from vertices
var v2 = geometry.vertices[vert_idx + 2]
var width = v2.pos[0] - v.pos[0]
var height = v2.pos[1] - v.pos[1]
// Transform size by camera zoom
var scale = this.get_camera_scale(this.current_camera)
width *= scale
height *= scale
// Apply vertex color as dither pattern (best we can do on 1-bit)
if (v.color.a < 1.0) {
var alpha_pattern = this.alpha_to_dither_pattern(v.color.a)
this.pd.graphics.setDitherPattern(alpha_pattern)
}
// Draw image
if (width != image.width || height != image.height) {
// Need scaling
var scaled = image.scaledImage(width / image.width, height / image.height)
this.pd.graphics.drawBitmap(scaled, screen_pos[0], screen_pos[1])
} else {
this.pd.graphics.drawBitmap(image, screen_pos[0], screen_pos[1])
}
}
}
PlaydateBackend.prototype.cmd_shader_pass = function(cmd) {
// NO SHADERS ON PLAYDATE
// Degrade gracefully based on shader type
var shader = cmd.shader
var input = cmd.input
var params = cmd.params
if (shader == 'threshold') {
// Threshold: Just copy input (or could dither based on threshold)
console.warn('Threshold shader not supported on Playdate, copying')
this.copy_bitmap(input.bitmap, this.current_target.bitmap)
}
else if (shader == 'gaussian_blur') {
// Blur: Box blur in CPU (slow but possible)
console.warn('Blur shader using CPU fallback')
this.cpu_box_blur(input.bitmap, this.current_target.bitmap, params.radius || 5)
}
else if (shader == 'add_textures') {
// Additive blend: Use XOR draw mode (not perfect but interesting)
console.warn('Additive blend approximated with XOR')
this.pd.graphics.setImageDrawMode(this.pd.graphics.kDrawModeXOR)
this.pd.graphics.drawBitmap(input.bitmap, 0, 0)
}
else if (shader == 'crt_filter') {
// CRT: Not possible, just copy
console.warn('CRT filter not supported on Playdate, copying')
this.copy_bitmap(input.bitmap, this.current_target.bitmap)
}
else {
// Unknown shader: copy
console.warn(`Shader ${shader} not supported on Playdate, copying`)
this.copy_bitmap(input.bitmap, this.current_target.bitmap)
}
}
PlaydateBackend.prototype.cmd_apply_mask = function(cmd) {
// PLAYDATE HAS NATIVE MASK SUPPORT!
var content = cmd.content_texture.bitmap
var mask = cmd.mask_texture.bitmap
var invert = cmd.invert
if (invert) {
// Invert mask first
var inverted = this.pd.graphics.newBitmap(mask.width, mask.height)
this.pd.graphics.pushContext(inverted)
this.pd.graphics.setImageDrawMode(this.pd.graphics.kDrawModeInverted)
this.pd.graphics.drawBitmap(mask, 0, 0)
this.pd.graphics.popContext()
mask = inverted
}
// Set mask on content bitmap
content.setMask(mask)
// Draw masked content to current target
this.pd.graphics.drawBitmap(content, 0, 0)
// Clear mask (don't leave it set)
content.setMask(null)
}
PlaydateBackend.prototype.cmd_composite = function(cmd) {
var base = cmd.base_texture.bitmap
var overlay = cmd.overlay_texture.bitmap
var mode = cmd.mode
// Draw base
this.pd.graphics.drawBitmap(base, 0, 0)
// Draw overlay with appropriate mode
var draw_mode = this.composite_mode_to_draw_mode(mode)
this.pd.graphics.setImageDrawMode(draw_mode)
this.pd.graphics.drawBitmap(overlay, 0, 0)
// Reset draw mode
this.pd.graphics.setImageDrawMode(this.pd.graphics.kDrawModeCopy)
}
PlaydateBackend.prototype.cmd_blit = function(cmd) {
var bitmap = cmd.texture.bitmap
var dst_rect = cmd.dst_rect
var filter = cmd.filter
// Scale bitmap to fit dst_rect
var scale_x = dst_rect.width / bitmap.width
var scale_y = dst_rect.height / bitmap.height
if (scale_x != 1.0 || scale_y != 1.0) {
// Playdate only supports nearest-neighbor scaling
var scaled = bitmap.scaledImage(scale_x, scale_y)
this.pd.graphics.drawBitmap(scaled, dst_rect.x, dst_rect.y)
} else {
this.pd.graphics.drawBitmap(bitmap, dst_rect.x, dst_rect.y)
}
}
PlaydateBackend.prototype.cmd_clear = function(cmd) {
var color = cmd.color
var pattern = this.color_to_dither_pattern(color)
this.pd.graphics.setDitherPattern(pattern)
this.pd.graphics.fillRect(0, 0, 400, 240) // screen size
}
// ========================================================================
// HELPER FUNCTIONS
// ========================================================================
PlaydateBackend.prototype.world_to_screen = function(world_pos, camera) {
if (!camera) return world_pos
// Simple orthographic transform
var cam_x = camera.pos[0]
var cam_y = camera.pos[1]
var scale = this.get_camera_scale(camera)
var screen_x = (world_pos[0] - cam_x) * scale + 200 // center X
var screen_y = (world_pos[1] - cam_y) * scale + 120 // center Y
return [screen_x, screen_y]
}
PlaydateBackend.prototype.get_camera_scale = function(camera) {
if (!camera) return 1
// Scale based on camera width vs screen width
return 400 / camera.width
}
PlaydateBackend.prototype.material_to_draw_mode = function(material) {
var blend = material.blend || 'alpha'
// Map blend modes to Playdate draw modes
var mode_map = {
'alpha': this.pd.graphics.kDrawModeCopy,
'add': this.pd.graphics.kDrawModeXOR, // approximation
'multiply': this.pd.graphics.kDrawModeNXOR // approximation
}
return mode_map[blend] || this.pd.graphics.kDrawModeCopy
}
PlaydateBackend.prototype.composite_mode_to_draw_mode = function(mode) {
var mode_map = {
'over': this.pd.graphics.kDrawModeCopy,
'add': this.pd.graphics.kDrawModeXOR,
'multiply': this.pd.graphics.kDrawModeNXOR
}
return mode_map[mode] || this.pd.graphics.kDrawModeCopy
}
PlaydateBackend.prototype.color_to_dither_pattern = function(color) {
// Convert RGBA color to 8x8 dither pattern
// Grayscale value determines density
var gray = color.r * 0.299 + color.g * 0.587 + color.b * 0.114
return this.gray_to_dither_pattern(gray)
}
PlaydateBackend.prototype.gray_to_dither_pattern = function(gray) {
// Bayer matrix dither patterns for different gray levels
var patterns = [
// 0% (black)
[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
// ~25%
[0x88, 0x00, 0x22, 0x00, 0x88, 0x00, 0x22, 0x00],
// ~50%
[0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA],
// ~75%
[0x77, 0xFF, 0xDD, 0xFF, 0x77, 0xFF, 0xDD, 0xFF],
// 100% (white)
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]
]
var index = floor(gray * (length(patterns) - 1))
return patterns[index]
}
PlaydateBackend.prototype.alpha_to_dither_pattern = function(alpha) {
return this.gray_to_dither_pattern(alpha)
}
PlaydateBackend.prototype.copy_bitmap = function(src, dst) {
this.pd.graphics.pushContext(dst)
this.pd.graphics.drawBitmap(src, 0, 0)
this.pd.graphics.popContext()
}
PlaydateBackend.prototype.get_image = function(name) {
// Look up image from asset system
// Return LCDBitmap*
return this.pd.graphics.imagetable(name)[1]
}
// ========================================================================
// CPU FALLBACKS FOR MISSING GPU FEATURES
// ========================================================================
PlaydateBackend.prototype.cpu_box_blur = function(src, dst, radius) {
// Simple box blur implementation in CPU
// This is SLOW but correct
var width = src.width
var height = src.height
// Get pixel data (Playdate API for accessing bitmap pixels)
var src_data = src.getData()
var dst_data = dst.getData()
for (var y = 0; y < height; y++) {
for (var x = 0; x < width; x++) {
var sum = 0
var count = 0
// Sample neighborhood
for (var dy = -radius; dy <= radius; dy++) {
for (var dx = -radius; dx <= radius; dx++) {
var sx = x + dx
var sy = y + dy
if (sx >= 0 && sx < width && sy >= 0 && sy < height) {
sum += src_data[sy * width + sx]
count++
}
}
}
dst_data[y * width + x] = sum / count
}
}
dst.setData(dst_data)
}