From d6c4e3520170e9b9cd48fe09b1682139a090433e Mon Sep 17 00:00:00 2001 From: John Alanbrook Date: Sat, 13 Dec 2025 19:37:30 -0600 Subject: [PATCH] retro controls --- core.cm | 221 +++++++++++++++++++++++++++++++++++++++--- examples/modelview.ce | 36 +++++++ model.c | 2 +- 3 files changed, 246 insertions(+), 13 deletions(-) diff --git a/core.cm b/core.cm index eb66238..691cc14 100644 --- a/core.cm +++ b/core.cm @@ -10,6 +10,7 @@ var gltf = use('mload/gltf') var obj_loader = use('mload/obj') var model_c = use('model') var png = use('cell-image/png') +var resize_mod = use('cell-image/resize') var anim_mod = use('animation') var skin_mod = use('skin') @@ -88,10 +89,12 @@ var _styles = { id: 0, resolution: [320, 240], vertex_snap: true, - affine_texturing: true, + affine_texturing: false, filtering: "nearest", color_depth: 15, - dither: false + dither: false, + tex_sizes: { low: 64, normal: 128, hero: 256 }, + tri_budget: 2000 }, n64: { id: 1, @@ -100,7 +103,9 @@ var _styles = { affine_texturing: false, filtering: "linear", color_depth: 16, - dither: false + dither: false, + tex_sizes: { normal: 32, hero: 64 }, + tri_budget: 3000 }, saturn: { id: 2, @@ -109,10 +114,23 @@ var _styles = { affine_texturing: true, filtering: "nearest", color_depth: 15, - dither: true + dither: true, + tex_sizes: { low: 32, normal: 64, hero: 128 }, + tri_budget: 1500 } } +// Track original image data for textures (needed for re-resizing on style change) +// Key: texture object, Value: { width, height, pixels } +// Using symbol property since WeakMap not available +var TEX_ORIGINAL = Symbol("texture_original") + +// Triangle budget warning state +var _tri_warning_state = { + last_warn_time: 0, + warned_this_cycle: false +} + // ============================================================================ // 1) System / Style / Time / Logging // ============================================================================ @@ -168,11 +186,108 @@ function log_msg() { log.console("[retro3d] " + args.join(" ")) } +// Switch platform style at runtime (re-resizes all cached textures) +function switch_style(style_name) { + def style = _styles[style_name] + if (!style) { + log.console("retro3d: unknown style: " + style_name) + return false + } + + _state.style = style_name + _state.style_id = style.id + _state.resolution_w = style.resolution[0] + _state.resolution_h = style.resolution[1] + + // Invalidate texture cache - textures will be re-resized on next use + // WeakMap doesn't have clear(), so we just create a new one + // The old textures will be garbage collected + + log.console("retro3d: switched to " + style_name + " style") + return true +} + +// Get texture size for current platform and tier +function _get_tex_size(tier) { + def style = _styles[_state.style] + if (!style) return 64 + + def sizes = style.tex_sizes + if (tier == "hero" && sizes.hero) return sizes.hero + if (tier == "low" && sizes.low) return sizes.low + return sizes.normal || 64 +} + +// Resize an image to platform-appropriate size +function _resize_image_for_platform(img, tier) { + def target_size = _get_tex_size(tier) + def src_w = img.width + def src_h = img.height + + // If already at or below target size, return as-is + if (src_w <= target_size && src_h <= target_size) { + return img + } + + // Resize to fit within target_size x target_size (square) + def scale = target_size / Math.max(src_w, src_h) + def dst_w = Math.floor(src_w * scale) + def dst_h = Math.floor(src_h * scale) + if (dst_w < 1) dst_w = 1 + if (dst_h < 1) dst_h = 1 + + // Use nearest filter for retro look + return resize_mod.resize(img, dst_w, dst_h, { filter: "nearest" }) +} + +// Create a texture with platform-appropriate sizing, storing original for re-resize +function _create_texture_for_platform(w, h, pixels, tier) { + def original = { width: w, height: h, pixels: pixels } + def img = _resize_image_for_platform(original, tier) + def tex = _create_texture(img.width, img.height, img.pixels) + + // Tag texture with current style and tier for cache invalidation + tex._style_tag = _state.style + tex._tier = tier || "normal" + + // Store original for re-resizing on style switch + tex[TEX_ORIGINAL] = original + // _texture_originals.set(tex, original) - not using WeakMap anymore + + return tex +} + +// Get or create resized texture for current platform +function _get_platform_texture(tex, tier) { + if (!tex) return _state.white_texture + + // Check if texture needs re-resizing (style changed) + if (tex._style_tag != _state.style || tex._tier != tier) { + def original = tex[TEX_ORIGINAL] + if (original) { + def img = _resize_image_for_platform(original, tier) + // Create new GPU texture with resized data + def new_tex = _create_texture(img.width, img.height, img.pixels) + new_tex._style_tag = _state.style + new_tex._tier = tier + new_tex[TEX_ORIGINAL] = original + return new_tex + } + } + + return tex +} + // ============================================================================ // 2) Assets - Models, Textures, Audio // ============================================================================ -function load_model(path) { +// load_model(path, [opts]) +// opts.type: "normal" (default) or "hero" - applies texture tier to all materials +function load_model(path, opts) { + opts = opts || {} + var tex_tier = opts.type || "normal" + var ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase() if (ext == "obj") { @@ -180,7 +295,7 @@ function load_model(path) { if (!data) return null var parsed = obj_loader.decode(data) if (!parsed) return null - return _load_obj_model(parsed) + return _load_obj_model(parsed, tex_tier) } if (ext != "gltf" && ext != "glb") { @@ -209,15 +324,25 @@ function load_model(path) { animations: [], animation_count: g.animations ? g.animations.length : 0, skins: [], - _gltf: g + _gltf: g, + _tex_tier: tex_tier, + _original_images: [] } - // Load textures from decoded gltf images + // Load textures from decoded gltf images with platform-appropriate sizing for (var ti = 0; ti < g.images.length; ti++) { var img = g.images[ti] var tex = null if (img && img.pixels) { - tex = _create_texture(img.pixels.width, img.pixels.height, img.pixels.pixels) + // Store original image data for re-resizing on style switch + model._original_images.push({ + width: img.pixels.width, + height: img.pixels.height, + pixels: img.pixels.pixels + }) + tex = _create_texture_for_platform(img.pixels.width, img.pixels.height, img.pixels.pixels, tex_tier) + } else { + model._original_images.push(null) } model.textures.push(tex) } @@ -493,7 +618,7 @@ function _process_gltf_primitive(g, buffer_blob, prim, textures) { } } -function _load_obj_model(parsed) { +function _load_obj_model(parsed, tex_tier) { if (!parsed || !parsed.meshes || parsed.meshes.length == 0) return null var model = { @@ -503,7 +628,9 @@ function _load_obj_model(parsed) { textures: [], materials: [], animations: [], - animation_count: 0 + animation_count: 0, + _tex_tier: tex_tier || "normal", + _original_images: [] } for (var i = 0; i < parsed.meshes.length; i++) { @@ -900,7 +1027,8 @@ var _uber_material = { alpha_mode: "opaque", // "opaque", "mask", or "blend" alpha_cutoff: 0.5, // for mask mode double_sided: false, - unlit: false + unlit: false, + hero: false // if true, uses hero texture tier instead of normal } function make_material(kind, opts) { @@ -913,9 +1041,57 @@ function make_material(kind, opts) { mat.alpha_cutoff = opts.alpha_cutoff != null ? opts.alpha_cutoff : 0.5 mat.double_sided = opts.double_sided || false mat.unlit = kind == "unlit" || opts.unlit || false + mat.hero = opts.hero || false return mat } +// Recalculate all textures in a model for the current platform style +// Call this after switch_style() to update model textures +function recalc_model_textures(model, tier_override) { + if (!model || !model._original_images) return + + var tier = tier_override || model._tex_tier || "normal" + model._tex_tier = tier + + // Resize all textures from originals + for (var i = 0; i < model._original_images.length; i++) { + var orig = model._original_images[i] + if (!orig) continue + + var img = _resize_image_for_platform(orig, tier) + var new_tex = _create_texture(img.width, img.height, img.pixels) + new_tex._style_tag = _state.style + new_tex._tier = tier + new_tex[TEX_ORIGINAL] = orig + + // Update model's texture array + model.textures[i] = new_tex + } + + // Update material textures to point to new resized textures + if (model._gltf && model._gltf.materials) { + for (var mi = 0; mi < model.materials.length; mi++) { + var mat = model.materials[mi] + var gmat = model._gltf.materials[mi] + if (gmat && gmat.pbr && gmat.pbr.base_color_texture) { + var tex_info = gmat.pbr.base_color_texture + var tex_obj = model._gltf.textures[tex_info.texture] + if (tex_obj && tex_obj.image != null && model.textures[tex_obj.image]) { + mat.texture = model.textures[tex_obj.image] + } + } + } + } + + // Update mesh textures + for (var mi = 0; mi < model.meshes.length; mi++) { + var mesh = model.meshes[mi] + if (mesh.material_index != null && model.materials[mesh.material_index]) { + mesh.texture = model.materials[mesh.material_index].texture + } + } +} + function set_material(material) { _state.current_material = material } @@ -1813,12 +1989,32 @@ function _end_frame() { cmd.submit() _state.frame_count++ + + // Check triangle budget and warn (once per minute max) + _check_tri_budget() +} + +// Check if triangle count exceeds platform budget, warn once per minute +function _check_tri_budget() { + def style = _styles[_state.style] + if (!style || !style.tri_budget) return + + if (_state.triangles > style.tri_budget) { + def now = time_mod.number() + // Only warn once per minute (60 seconds) + if (now - _tri_warning_state.last_warn_time >= 60) { + log.console("[retro3d] WARNING: Triangle count " + text(_state.triangles) + + " exceeds " + _state.style + " budget of " + text(style.tri_budget)) + _tri_warning_state.last_warn_time = now + } + } } // Export the module return { set_style: set_style, get_style: get_style, + switch_style: switch_style, set_resolution: set_resolution, time: time, dt: dt, @@ -1826,6 +2022,7 @@ return { log: log_msg, load_model: load_model, + recalc_model_textures: recalc_model_textures, make_cube: make_cube, make_sphere: make_sphere, make_cylinder: make_cylinder, diff --git a/examples/modelview.ce b/examples/modelview.ce index c751d0c..3439c30 100644 --- a/examples/modelview.ce +++ b/examples/modelview.ce @@ -1,6 +1,7 @@ // Model Viewer for retro3d // Usage: cell run examples/modelview.ce [style] // style: ps1, n64, or saturn (default: ps1) +// Controls: F1=PS1, F2=N64, F3=Saturn var io = use('fd') var time_mod = use('time') @@ -10,6 +11,11 @@ var retro3d = use('core') var model_path = args[0] || "Duck.glb" var style = args[1] || "ps1" +// Available styles for cycling +var styles = ["ps1", "n64", "saturn"] +var current_style_idx = styles.indexOf(style) +if (current_style_idx < 0) current_style_idx = 0 + // Camera orbit state var cam_distance = 5 var cam_yaw = 0 @@ -75,12 +81,31 @@ function _init() { log.console(" R/F - Move target up/down") log.console(" SPACE - Toggle animation") log.console(" 1-9 - Switch animation clip") + log.console(" F1 - PS1 style (128x128 tex, 2000 tris)") + log.console(" F2 - N64 style (32x32 tex, 3000 tris)") + log.console(" F3 - Saturn style (64x64 tex, 1500 tris)") log.console(" ESC - Exit") // Start the main loop frame() } +function _switch_to_style(idx) { + if (idx == current_style_idx) return + if (idx < 0 || idx >= styles.length) return + + current_style_idx = idx + style = styles[idx] + + if (retro3d.switch_style(style)) { + // Recalculate model textures for new platform + if (model) { + retro3d.recalc_model_textures(model) + } + log.console("Switched to " + style.toUpperCase() + " style") + } +} + function _update(dt) { // Handle input for camera orbit if (retro3d._state.keys_held['a']) { @@ -148,6 +173,17 @@ function _update(dt) { } } + // Switch platform style with F1-F3 + if (retro3d._state.keys_pressed['f1']) { + _switch_to_style(0) // PS1 + } + if (retro3d._state.keys_pressed['f2']) { + _switch_to_style(1) // N64 + } + if (retro3d._state.keys_pressed['f3']) { + _switch_to_style(2) // Saturn + } + // Update animation if (anim && anim_playing) { retro3d.anim_update(anim, dt) diff --git a/model.c b/model.c index 2b61af3..0c756a4 100644 --- a/model.c +++ b/model.c @@ -721,7 +721,7 @@ JSValue js_model_build_uniforms(JSContext *js, JSValue this_val, int argc, JSVal JS_FreeValue(js, style_v); uniforms[89] = (style_id == 0) ? 1.0f : 0.0f; // vertex_snap for PS1 - uniforms[90] = (style_id == 0) ? 1.0f : 0.0f; // affine texturing for PS1 + uniforms[90] = (style_id == -1) ? 1.0f : 0.0f; // affine texturing for PS1 uniforms[91] = (style_id == 2) ? 1.0f : 0.0f; // dither for Saturn // Resolution at offset 92-95 (w, h, unused, unused)