// lance3d - retro 3D fantasy console var time_mod = use('time') var model_c = use('model') var anim_mod = use('animation') var skin_mod = use('skin') // Import sub-modules var backend = use('sdl') var input_mod = use('input') var collision_mod = use('collision') var resources_mod = use('resources') var camera_mod = use('camera') var math_mod = use('math') var math = use('math/radians') // Style configurations (PS1, N64, Saturn) var _styles = { ps1: { name: "ps1", id: 0, resolution: [320, 240], vertex_snap: true, affine_texturing: false, filtering: "nearest", color_depth: 15, dither: false, tex_sizes: { low: 64, normal: 128, hero: 256 }, tri_budget: 2000 }, n64: { name: "n64", id: 1, resolution: [320, 240], vertex_snap: false, affine_texturing: false, filtering: "linear", color_depth: 16, dither: false, tex_sizes: { normal: 32, hero: 64 }, tri_budget: 3000 }, saturn: { name: "saturn", id: 2, resolution: [320, 224], vertex_snap: false, affine_texturing: true, filtering: "nearest", color_depth: 15, dither: true, tex_sizes: { low: 32, normal: 64, hero: 128 }, tri_budget: 1500 } } // Triangle budget warning state var _tri_warning_state = { last_warn_time: 0 } // Internal state var _state = { style: null, style_id: 0, resolution_w: 320, resolution_h: 240, boot_time: 0, dt: 1/60, frame_count: 0, draw_calls: 0, triangles: 0, // Environment (lighting/fog) lighting: { sun_dir: [0.3, -1, 0.2], sun_color: [1, 1, 1], ambient: [0.25, 0.25, 0.25] }, fog: { enabled: false, color: [0.5, 0.6, 0.7], near: 10, far: 80 }, // Pending draws _pending_draws: [], _clear_color: [0, 0, 0, 1], _clear_depth: true } // Default material prototype var _default_material = { color_map: null, paint: [1, 1, 1, 1], coverage: "opaque", face: "single", lamp: "lit" } // ============================================================================ // System / Style / Time / Logging // ============================================================================ function set_style(style_name) { if (_state.style != null) return // Already set var style = _styles[style_name] if (!style) { log.console("lance3d: unknown style: " + style_name) return } _state.style = style_name _state.style_id = style.id _state.resolution_w = style.resolution[0] _state.resolution_h = style.resolution[1] _state.boot_time = time_mod.number() // Initialize backend backend.init({ title: "lance3d - " + style_name, width: _state.resolution_w, height: _state.resolution_h }) // Set up resources module with backend resources_mod.set_backend(backend) // Set camera aspect ratio camera_mod.set_aspect(_state.resolution_w / _state.resolution_h) } function get_style() { return _state.style } function get_style_config() { return _styles[_state.style] } function set_resolution(w, h) { _state.resolution_w = w _state.resolution_h = h camera_mod.set_aspect(w / h) } // Switch platform style at runtime (re-resizes all cached textures) function switch_style(style_name) { var style = _styles[style_name] if (!style) { log.console("lance3d: 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] camera_mod.set_aspect(_state.resolution_w / _state.resolution_h) log.console("lance3d: switched to " + style_name + " style") return true } function time() { return time_mod.number() - _state.boot_time } function dt() { return _state.dt } function stat(name) { if (name == "draw_calls") return _state.draw_calls if (name == "triangles") return _state.triangles if (name == "fps") return 1 / _state.dt if (name == "memory_bytes") return 0 return 0 } function log_msg() { var args = [] for (var i = 0; i < arguments.length; i++) { args.push(text(arguments[i])) } log.console("[lance3d] " + args.join(" ")) } function set_lighting(opts) { if (opts.sun_dir) { var d = opts.sun_dir var len = math.sqrt(d[0]*d[0] + d[1]*d[1] + d[2]*d[2]) if (len > 0) { _state.lighting.sun_dir = [d[0]/len, d[1]/len, d[2]/len] } } if (opts.sun_color) _state.lighting.sun_color = opts.sun_color.slice() if (opts.ambient) _state.lighting.ambient = opts.ambient.slice() } function set_fog(opts) { if (opts.enabled != null) _state.fog.enabled = opts.enabled if (opts.color) _state.fog.color = opts.color.slice() if (opts.near != null) _state.fog.near = opts.near if (opts.far != null) _state.fog.far = opts.far } // ============================================================================ // Draw API - Models & Meshes // ============================================================================ function load_model(path, opts) { opts = opts || {} var tex_tier = opts.type || "normal" var style = _styles[_state.style] return resources_mod.load_model(path, style, tex_tier) } // Recalculate model textures for current style (call after switch_style) function recalc_model_textures(model) { var style = _styles[_state.style] var tier = model._internal ? model._internal._tex_tier : "normal" resources_mod.recalc_model_textures(model, style, tier) } function make_cube(w, h, d) { var hw = w / 2, hh = h / 2, hd = d / 2 var positions = [ -hw, -hh, hd, hw, -hh, hd, hw, hh, hd, -hw, hh, hd, -hw, -hh, -hd, -hw, hh, -hd, hw, hh, -hd, hw, -hh, -hd, -hw, hh, -hd, -hw, hh, hd, hw, hh, hd, hw, hh, -hd, -hw, -hh, -hd, hw, -hh, -hd, hw, -hh, hd, -hw, -hh, hd, hw, -hh, -hd, hw, hh, -hd, hw, hh, hd, hw, -hh, hd, -hw, -hh, -hd, -hw, -hh, hd, -hw, hh, hd, -hw, hh, -hd ] var normals = [ 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0,-1, 0, 0,-1, 0, 0,-1, 0, 0,-1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0,-1, 0, 0,-1, 0, 0,-1, 0, 0,-1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0 ] var uvs = [ 0,1, 1,1, 1,0, 0,0, 1,1, 1,0, 0,0, 0,1, 0,0, 0,1, 1,1, 1,0, 0,1, 1,1, 1,0, 0,0, 1,1, 1,0, 0,0, 0,1, 0,1, 1,1, 1,0, 0,0 ] var indices = [ 0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7, 8, 9,10, 8,10,11, 12,13,14, 12,14,15, 16,17,18, 16,18,19, 20,21,22, 20,22,23 ] return _make_mesh_from_arrays(positions, normals, uvs, indices) } function make_sphere(r, segments) { if (!segments) segments = 12 var positions = [] var normals = [] var uvs = [] var indices = [] for (var y = 0; y <= segments; y++) { var v = y / segments var theta = v * pi for (var x = 0; x <= segments; x++) { var u = x / segments var phi = u * 2 * pi var nx = math.sine(theta) * math.cosine(phi) var ny = math.cosine(theta) var nz = math.sine(theta) * math.sine(phi) positions.push(nx * r, ny * r, nz * r) normals.push(nx, ny, nz) uvs.push(u, v) } } for (var y = 0; y < segments; y++) { for (var x = 0; x < segments; x++) { var i = y * (segments + 1) + x indices.push(i, i + 1, i + segments + 1) indices.push(i + 1, i + segments + 2, i + segments + 1) } } return _make_mesh_from_arrays(positions, normals, uvs, indices) } function make_cylinder(r, h, segments) { if (!segments) segments = 12 var positions = [] var normals = [] var uvs = [] var indices = [] var hh = h / 2 for (var i = 0; i <= segments; i++) { var u = i / segments var angle = u * 2 * pi var nx = math.cosine(angle) var nz = math.sine(angle) positions.push(nx * r, -hh, nz * r) normals.push(nx, 0, nz) uvs.push(u, 1) positions.push(nx * r, hh, nz * r) normals.push(nx, 0, nz) uvs.push(u, 0) } for (var i = 0; i < segments; i++) { var base = i * 2 indices.push(base, base + 1, base + 2) indices.push(base + 1, base + 3, base + 2) } return _make_mesh_from_arrays(positions, normals, uvs, indices) } function make_plane(w, h) { var hw = w / 2, hh = h / 2 var positions = [-hw, 0, -hh, hw, 0, -hh, hw, 0, hh, -hw, 0, hh] var normals = [0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0] var uvs = [0, 0, 1, 0, 1, 1, 0, 1] var indices = [0, 1, 2, 0, 2, 3] return _make_mesh_from_arrays(positions, normals, uvs, indices) } function load_texture(path, opts) { opts = opts || {} var tier = opts.type || "normal" var style = _styles[_state.style] return resources_mod.load_texture(path, style, tier) } // ============================================================================ // Animation API // ============================================================================ function anim_info(model) { if (!model || !model._internal) return [] var internal = model._internal var result = [] for (var i = 0; i < internal.animations.length; i++) { var anim = internal.animations[i] result.push({ name: anim.name || ("clip_" + text(i)), duration: anim.duration || 0, index: i }) } return result } function sample_pose(model, name, time_val) { if (!model || !model._internal) return null var internal = model._internal // Find animation by name or index var anim_idx = -1 if (is_number(name)) { anim_idx = name } else { for (var i = 0; i < internal.animations.length; i++) { if (internal.animations[i].name == name) { anim_idx = i break } } } if (anim_idx < 0 || anim_idx >= internal.animations.length) { return null } var anim = internal.animations[anim_idx] var duration = anim.duration || 0 // Clamp time if (time_val < 0) time_val = 0 if (time_val > duration) time_val = duration // Create a temporary animation instance and sample it var instance = anim_mod.create_instance(internal) anim_mod.play(instance, anim_idx, false) anim_mod.set_time(instance, time_val) anim_mod.apply(instance) // Build pose: array of node transforms var pose = { _internal: internal, _anim_idx: anim_idx, _time: time_val, node_matrices: [] } for (var ni = 0; ni < internal.nodes.length; ni++) { pose.node_matrices.push(resources_mod.get_transform_world_matrix(internal.nodes[ni])) } // Build skin palettes if model has skins if (internal.skins && internal.skins.length > 0) { pose.skin_palettes = [] for (var si = 0; si < internal.skins.length; si++) { var skin = internal.skins[si] var world_matrices = [] for (var j = 0; j < skin.joints.length; j++) { var node_idx = skin.joints[j] var jnode = internal.nodes[node_idx] if (jnode) { world_matrices.push(resources_mod.get_transform_world_matrix(jnode)) } else { world_matrices.push(model_c.mat4_identity()) } } var palette = model_c.build_joint_palette(world_matrices, skin.inv_bind, skin.joint_count) pose.skin_palettes.push(palette) } } return pose } // ============================================================================ // Drawing // ============================================================================ function draw_model(model, transform, pose) { if (!model || !model._internal) return var internal = model._internal var view_matrix = camera_mod.get_view_matrix() var proj_matrix = camera_mod.get_proj_matrix() var extra_transform = transform || null // Get skin palettes from pose or build default var skin_palettes = [] if (pose && pose.skin_palettes) { skin_palettes = pose.skin_palettes } else if (internal.skins && internal.skins.length > 0) { for (var si = 0; si < internal.skins.length; si++) { var skin = internal.skins[si] var world_matrices = [] for (var j = 0; j < skin.joints.length; j++) { var node_idx = skin.joints[j] var jnode = internal.nodes[node_idx] if (jnode) { var jworld = resources_mod.get_transform_world_matrix(jnode) if (extra_transform) { jworld = model_c.mat4_mul(extra_transform, jworld) } world_matrices.push(jworld) } else { world_matrices.push(model_c.mat4_identity()) } } var palette = model_c.build_joint_palette(world_matrices, skin.inv_bind, skin.joint_count) skin_palettes.push(palette) } } // Draw each mesh in the model array for (var i = 0; i < model.length; i++) { var entry = model[i] var mesh = entry.mesh var mat = entry.material var node_idx = entry._node_index // Get node world matrix (from pose or computed) var node_world if (pose && pose.node_matrices && pose.node_matrices[node_idx]) { node_world = pose.node_matrices[node_idx] } else { node_world = resources_mod.get_transform_world_matrix(internal.nodes[node_idx]) } // Apply extra transform var world_matrix = extra_transform ? model_c.mat4_mul(extra_transform, node_world) : node_world var tex = mesh.texture || mat.color_map || backend.get_white_texture() var uniforms = _build_uniforms(world_matrix, view_matrix, proj_matrix, mat) var palette = null if (mesh.skinned && skin_palettes.length > 0) { palette = skin_palettes[0] } _queue_draw(mesh, uniforms, tex, mat, palette) } } function draw_mesh(mesh, transform, material) { var view_matrix = camera_mod.get_view_matrix() var proj_matrix = camera_mod.get_proj_matrix() var world_matrix = transform || model_c.mat4_identity() var mat = material || _default_material var tex = mat.color_map || backend.get_white_texture() var uniforms = _build_uniforms(world_matrix, view_matrix, proj_matrix, mat) _queue_draw(mesh, uniforms, tex, mat, null) } function draw_billboard(texture, x, y, z, size, mat) { if (!texture) return size = size || 1.0 mat = mat || _default_material var cam_eye = camera_mod.get_eye() var dx = cam_eye.x - x var dz = cam_eye.z - z var yaw = math.atan2(dx, dz) var q = math_mod.euler_to_quat(0, yaw, 0) var transform = math_mod.trs_matrix(x, y, z, q.x, q.y, q.z, q.w, size, size, 1) var quad = make_plane(1, 1) var billboard_mat = { color_map: texture, paint: mat.paint || [1, 1, 1, 1], coverage: mat.coverage || "cutoff", face: "double", lamp: "unlit" } draw_mesh(quad, transform, billboard_mat) } function draw_sprite(texture, x, y, size, mat) { // 2D sprite - uses orthographic projection // TODO: implement 2D sprite rendering } // ============================================================================ // Debug API // ============================================================================ function debug_point(vertex, size) { size = size || 1.0 // TODO: implement point rendering } function debug_line(vertex_a, vertex_b, width) { width = width || 1.0 // TODO: implement line rendering } function debug_grid(size, step, norm, color) { norm = norm || {x: 0, y: 1, z: 0} color = color || [0.5, 0.5, 0.5, 1] // TODO: implement grid rendering } // ============================================================================ // Frame management // ============================================================================ function clear(r, g, b, a) { if (a == null) a = 1.0 _state._clear_color = [r, g, b, a] _state._clear_depth = true } function _begin_frame() { _state.draw_calls = 0 _state.triangles = 0 _state._pending_draws = [] _state._clear_color = [0, 0, 0, 1] _state._clear_depth = true // Begin input frame input_mod.begin_frame() } function _process_events() { return input_mod.process_events() } function _end_frame() { // Submit all pending draws to backend var result = backend.submit_frame( _state._pending_draws, _state._clear_color, _state._clear_depth, _state.style_id ) _state.draw_calls = result.draw_calls _state.triangles = result.triangles _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() { var style = _styles[_state.style] if (!style || !style.tri_budget) return if (_state.triangles > style.tri_budget) { var now = time_mod.number() // Only warn once per minute (60 seconds) if (now - _tri_warning_state.last_warn_time >= 60) { log.console("[lance3d] WARNING: Triangle count " + text(_state.triangles) + " exceeds " + _state.style + " budget of " + text(style.tri_budget)) _tri_warning_state.last_warn_time = now } } } // ============================================================================ // Internal helpers // ============================================================================ function _queue_draw(mesh, uniforms, texture, mat, palette) { _state._pending_draws.push({ mesh: mesh, uniforms: uniforms, texture: texture, coverage: mat.coverage || "opaque", face: mat.face || "single", palette: palette }) } function _build_uniforms(model_mat, view_mat, proj_mat, mat) { var paint = mat.paint || [1, 1, 1, 1] var alpha_mode = 0 if (mat.coverage == "cutoff") alpha_mode = 1 else if (mat.coverage == "blend") alpha_mode = 2 var unlit = mat.lamp == "unlit" ? 1 : 0 return model_c.build_uniforms({ model: model_mat, view: view_mat, projection: proj_mat, ambient: _state.lighting.ambient, light_dir: _state.lighting.sun_dir, light_color: _state.lighting.sun_color, light_intensity: 1.0, fog_near: _state.fog.enabled ? _state.fog.near : 10000, fog_far: _state.fog.enabled ? _state.fog.far : 10001, fog_color: _state.fog.enabled ? _state.fog.color : null, tint: paint, style_id: _state.style_id, resolution_w: _state.resolution_w, resolution_h: _state.resolution_h, alpha_mode: alpha_mode, alpha_cutoff: 0.5, unlit: unlit }) } function _make_mesh_from_arrays(positions, normals, uvs, indices) { var vertex_count = positions.length / 3 var mesh = { vertex_count: vertex_count, index_count: indices.length } mesh.positions = model_c.f32_blob(positions) mesh.normals = normals && normals.length > 0 ? model_c.f32_blob(normals) : null mesh.uvs = uvs && uvs.length > 0 ? model_c.f32_blob(uvs) : null mesh.indices = model_c.u16_blob(indices) mesh.index_type = "uint16" var packed = model_c.pack_vertices(mesh) return { vertex_buffer: backend.create_vertex_buffer(packed.data), index_buffer: backend.create_index_buffer(mesh.indices), index_count: mesh.index_count, index_type: "uint16", vertex_count: vertex_count, texture: null, skinned: false } } // ============================================================================ // Export // ============================================================================ return { // System / Style set_style: set_style, get_style: get_style, time: time, dt: dt, stat: stat, log: log_msg, set_lighting: set_lighting, set_fog: set_fog, // Transform/Matrix identity_matrix: math_mod.identity_matrix, translation_matrix: math_mod.translation_matrix, rotation_matrix: math_mod.rotation_matrix, scale_matrix: math_mod.scale_matrix, trs_matrix: math_mod.trs_matrix, euler_to_quat: math_mod.euler_to_quat, euler_matrix: math_mod.euler_matrix, multiply_matrices: math_mod.multiply_matrices, // Draw API load_model: load_model, make_cube: make_cube, make_sphere: make_sphere, make_cylinder: make_cylinder, make_plane: make_plane, load_texture: load_texture, anim_info: anim_info, sample_pose: sample_pose, draw_model: draw_model, draw_mesh: draw_mesh, draw_billboard: draw_billboard, draw_sprite: draw_sprite, // Camera camera_look_at: camera_mod.look_at, camera_perspective: camera_mod.perspective, camera_ortho: camera_mod.ortho, // Collision add_collider_sphere: collision_mod.add_collider_sphere, add_collider_box: collision_mod.add_collider_box, remove_collider: collision_mod.remove_collider, overlaps: collision_mod.overlaps, raycast: collision_mod.raycast, // Input btn: input_mod.btn, btnp: input_mod.btnp, key: input_mod.key, keyp: input_mod.keyp, axis: input_mod.axis, switch_style, // Math seed: math_mod.seed, rand: math_mod.rand, irand: math_mod.irand, // Debug point: debug_point, line: debug_line, grid: debug_grid, // Frame management clear: clear, // Internal (for runner) _begin_frame: _begin_frame, _process_events: _process_events, _end_frame: _end_frame }