Files
retro3d/core.cm
2025-12-21 10:39:05 -06:00

782 lines
20 KiB
Plaintext

// 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 (typeof name == "number") {
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
}