436 lines
13 KiB
Plaintext
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
|
|
}
|
|
|
|
this.target_pool[pool_key].push(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)
|
|
}
|
|
|