billboards, sprites, forest example

This commit is contained in:
2025-12-14 00:08:40 -06:00
parent d6c4e35201
commit a223d3d2b3
5 changed files with 1180 additions and 7 deletions

558
core.cm
View File

@@ -6,6 +6,7 @@ var video = use('sdl3/video')
var gpu_mod = use('sdl3/gpu')
var events = use('sdl3/events')
var keyboard = use('sdl3/keyboard')
var mouse = use('sdl3/mouse')
var gltf = use('mload/gltf')
var obj_loader = use('mload/obj')
var model_c = use('model')
@@ -78,9 +79,18 @@ var _state = {
keys_held: {},
keys_pressed: {},
axes: [0, 0, 0, 0],
mouse_x: 0,
mouse_y: 0,
mouse_dx: 0,
mouse_dy: 0,
// RNG state
rng_seed: 12345
rng_seed: 12345,
// Sprite state
sprite_manifest: null, // { base_path: extension } mapping
sprite_cache: {}, // { path: sprite_object } cache
sprite_quad: null // Shared quad mesh for sprites/billboards
}
// Style configurations
@@ -658,6 +668,15 @@ function _load_obj_model(parsed, tex_tier) {
return model
}
function _flip_tri_winding(indices) {
for (var i = 0; i < indices.length; i += 3) {
var t = indices[i + 1]
indices[i + 1] = indices[i + 2]
indices[i + 2] = t
}
return indices
}
function make_cube(w, h, d) {
var hw = w / 2, hh = h / 2, hd = d / 2
@@ -703,7 +722,7 @@ function make_cube(w, h, d) {
16,17,18, 16,18,19,
20,21,22, 20,22,23
]
return _make_model_from_arrays(positions, normals, uvs, indices)
}
@@ -739,7 +758,7 @@ function make_sphere(r, segments) {
indices.push(i + 1, i + segments + 2, i + segments + 1)
}
}
return _make_model_from_arrays(positions, normals, uvs, indices)
}
@@ -773,7 +792,7 @@ function make_cylinder(r, h, segments) {
indices.push(base, base + 1, base + 2)
indices.push(base + 1, base + 3, base + 2)
}
return _make_model_from_arrays(positions, normals, uvs, indices)
}
@@ -786,6 +805,162 @@ function make_plane(w, h) {
return _make_model_from_arrays(positions, normals, uvs, indices)
}
function make_cone(r, h, segments) {
if (!segments) segments = 12
var positions = []
var normals = []
var uvs = []
var indices = []
// Apex vertex
var apex_y = h / 2
positions.push(0, apex_y, 0)
normals.push(0, 1, 0) // Pointing up for apex
uvs.push(0.5, 1)
// Base vertices
for (var i = 0; i <= segments; i++) {
var u = i / segments
var angle = u * 2 * 3.14159265
var x = Math.cos(angle) * r
var z = Math.sin(angle) * r
positions.push(x, -apex_y, z)
// Normals for base - pointing outward
normals.push(x / r, 0, z / r)
uvs.push(u, 0)
}
// Side triangles
for (var i = 0; i < segments; i++) {
var base_idx = i + 1
var next_base_idx = (i + 1) % segments + 1
indices.push(0, base_idx, next_base_idx)
}
// Base triangles (fan)
for (var i = 1; i < segments; i++) {
indices.push(1, i + 1, i + 2)
}
_flip_tri_winding(indices)
return _make_model_from_arrays(positions, normals, uvs, indices)
}
function make_capsule(r, h, segments) {
if (!segments) segments = 12
var positions = []
var normals = []
var uvs = []
var indices = []
var vertex_offset = 0
// Cylinder body height (total height minus two radius caps)
var body_height = Math.max(0, h - 2 * r)
var half_body = body_height / 2
// Top hemisphere
for (var lat = 0; lat <= segments / 2; lat++) {
var v = lat / (segments / 2)
var phi = v * 3.14159265 / 2 // 0 to π/2
for (var lon = 0; lon <= segments; lon++) {
var u = lon / segments
var theta = u * 2 * 3.14159265
var x = Math.sin(phi) * Math.cos(theta) * r
var y = Math.cos(phi) * r + half_body
var z = Math.sin(phi) * Math.sin(theta) * r
positions.push(x, y, z)
normals.push(x / r, y / r, z / r)
uvs.push(u, v)
}
}
// Bottom hemisphere
vertex_offset += (segments / 2 + 1) * (segments + 1)
for (var lat = 0; lat <= segments / 2; lat++) {
var v = lat / (segments / 2)
var phi = v * 3.14159265 / 2 + 3.14159265 / 2 // π/2 to π
for (var lon = 0; lon <= segments; lon++) {
var u = lon / segments
var theta = u * 2 * 3.14159265
var x = Math.sin(phi) * Math.cos(theta) * r
var y = Math.cos(phi) * r - half_body
var z = Math.sin(phi) * Math.sin(theta) * r
positions.push(x, y, z)
normals.push(x / r, y / r, z / r)
uvs.push(u, 1 - v)
}
}
// Cylinder body (only if there's body height)
if (body_height > 0) {
vertex_offset += (segments / 2 + 1) * (segments + 1)
// Top ring
for (var i = 0; i <= segments; i++) {
var u = i / segments
var angle = u * 2 * 3.14159265
var x = Math.cos(angle) * r
var z = Math.sin(angle) * r
positions.push(x, half_body, z)
normals.push(x / r, 0, z / r)
uvs.push(u, 0.5)
}
// Bottom ring
for (var i = 0; i <= segments; i++) {
var u = i / segments
var angle = u * 2 * 3.14159265
var x = Math.cos(angle) * r
var z = Math.sin(angle) * r
positions.push(x, -half_body, z)
normals.push(x / r, 0, z / r)
uvs.push(u, 0)
}
}
// Generate indices for top hemisphere
for (var lat = 0; lat < segments / 2; lat++) {
for (var lon = 0; lon < segments; lon++) {
var a = lat * (segments + 1) + lon
var b = a + segments + 1
indices.push(a, b, a + 1)
indices.push(b, b + 1, a + 1)
}
}
// Generate indices for bottom hemisphere
var bottom_offset = (segments / 2 + 1) * (segments + 1)
for (var lat = 0; lat < segments / 2; lat++) {
for (var lon = 0; lon < segments; lon++) {
var a = bottom_offset + lat * (segments + 1) + lon
var b = a + segments + 1
indices.push(a, a + 1, b)
indices.push(b, a + 1, b + 1)
}
}
// Generate indices for cylinder body
if (body_height > 0) {
var cylinder_offset = 2 * (segments / 2 + 1) * (segments + 1)
for (var i = 0; i < segments; i++) {
var top_idx = cylinder_offset + i
var bottom_idx = cylinder_offset + segments + 1 + i
indices.push(top_idx, bottom_idx, top_idx + 1)
indices.push(bottom_idx, bottom_idx + 1, top_idx + 1)
}
}
_flip_tri_winding(indices)
return _make_model_from_arrays(positions, normals, uvs, indices)
}
function make_model(vertices, indices, uvs_in, colors_in) {
var vertex_count = vertices.length / 3
var positions = vertices
@@ -981,6 +1156,80 @@ function camera_get() {
return Object.assign({}, _state.camera)
}
function _v3_cross(ax, ay, az, bx, by, bz) {
return {
x: ay * bz - az * by,
y: az * bx - ax * bz,
z: ax * by - ay * bx
}
}
function _v3_norm(x, y, z) {
var len = Math.sqrt(x * x + y * y + z * z)
if (len <= 0) return { x: 0, y: 0, z: 0 }
return { x: x / len, y: y / len, z: z / len }
}
function screen_ray(screen_x, screen_y) {
var c = _state.camera
var w = _state.resolution_w * 2
var h = _state.resolution_h * 2
if (_state.window && _state.window.sizeInPixels) {
var s = _state.window.sizeInPixels
if (s && s.length >= 2) {
w = s[0]
h = s[1]
}
}
if (w <= 0 || h <= 0) return null
var nx = (screen_x / w) * 2 - 1
var ny = 1 - (screen_y / h) * 2
var fx = c.target_x - c.x
var fy = c.target_y - c.y
var fz = c.target_z - c.z
var f = _v3_norm(fx, fy, fz)
var up = _v3_norm(c.up_x, c.up_y, c.up_z)
var r = _v3_cross(f.x, f.y, f.z, up.x, up.y, up.z)
r = _v3_norm(r.x, r.y, r.z)
var u = _v3_cross(r.x, r.y, r.z, f.x, f.y, f.z)
u = _v3_norm(u.x, u.y, u.z)
var aspect = _state.resolution_w / _state.resolution_h
var tan_half = Math.tan((c.fov * 0.5) * 3.14159265 / 180)
var dx = f.x + r.x * (nx * aspect * tan_half) + u.x * (ny * tan_half)
var dy = f.y + r.y * (nx * aspect * tan_half) + u.y * (ny * tan_half)
var dz = f.z + r.z * (nx * aspect * tan_half) + u.z * (ny * tan_half)
var d = _v3_norm(dx, dy, dz)
return {
ox: c.x,
oy: c.y,
oz: c.z,
dx: d.x,
dy: d.y,
dz: d.z
}
}
function screen_cast_ground(screen_x, screen_y, ground_y) {
ground_y = ground_y != null ? ground_y : 0
var ray = screen_ray(screen_x, screen_y)
if (!ray) return null
if (ray.dy == 0) return null
var t = (ground_y - ray.oy) / ray.dy
if (t <= 0) return null
return {
x: ray.ox + ray.dx * t,
y: ray.oy + ray.dy * t,
z: ray.oz + ray.dz * t,
t: t
}
}
// ============================================================================
// 5) Render State Stack
// ============================================================================
@@ -1314,6 +1563,283 @@ function end() {
_state.im_vertices = []
}
// ============================================================================
// 7b) Sprites & Billboards
// ============================================================================
// Supported image extensions for sprite loading
var _sprite_extensions = ["png", "jpg", "jpeg", "gif", "bmp", "tga"]
// Scan a folder and build manifest of available sprites (base_path -> extension)
function _scan_sprite_folder(folder) {
if (_state.sprite_manifest) return // Already scanned
_state.sprite_manifest = {}
var list = io.readdir(folder)
if (!list) return
for (var i = 0; i < list.length; i++) {
var item = list[i]
var dot_idx = item.lastIndexOf('.')
if (dot_idx < 0) continue
var base = item.slice(0, dot_idx)
var ext = item.slice(dot_idx + 1).toLowerCase()
// Check if it's a supported image extension
var supported = false
for (var j = 0; j < _sprite_extensions.length; j++) {
if (ext == _sprite_extensions[j]) {
supported = true
break
}
}
if (!supported) continue
// Build full base path (folder/base)
var base_path = folder + "/" + base
// Only store first found extension (no overwriting)
if (!_state.sprite_manifest[base_path]) {
_state.sprite_manifest[base_path] = ext
}
}
}
// Load a sprite by base path (e.g. "assets/goblin" checks for goblin.png, goblin.jpg, etc)
function load_sprite(path) {
// Check cache first
if (_state.sprite_cache[path]) {
return _state.sprite_cache[path]
}
// Extract folder and base name
var slash_idx = path.lastIndexOf('/')
var folder = slash_idx >= 0 ? path.slice(0, slash_idx) : "."
// Scan folder if not already done
_scan_sprite_folder(folder)
// Look up in manifest
var ext = _state.sprite_manifest[path]
if (!ext) {
log.console("retro3d: sprite not found: " + path)
return null
}
// Load the image
var full_path = path + "." + ext
var data = io.slurp(full_path)
if (!data) {
log.console("retro3d: failed to load sprite file: " + full_path)
return null
}
// Decode based on extension
var img = null
if (ext == "png") {
img = png.decode(data)
} else {
// For other formats, try png decoder (may need to add other decoders)
img = png.decode(data)
}
if (!img) {
log.console("retro3d: failed to decode sprite: " + full_path)
return null
}
// Create texture
var tex = _create_texture(img.width, img.height, img.pixels)
// Create sprite object
var sprite = {
texture: tex,
width: img.width,
height: img.height,
path: path
}
// Cache it
_state.sprite_cache[path] = sprite
return sprite
}
// Create shared quad mesh for sprites/billboards (unit quad, bottom-left origin)
function _get_sprite_quad() {
if (_state.sprite_quad) return _state.sprite_quad
// Unit quad with bottom-left at origin, extends to (1, 1)
// Positions: x, y, z (z=0 for 2D)
var positions = [
0, 0, 0, // bottom-left
1, 0, 0, // bottom-right
1, 1, 0, // top-right
0, 1, 0 // top-left
]
// Normals pointing towards camera (negative Z in view space, but we'll use +Z for billboard facing)
var normals = [
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1
]
// UVs: standard texture coordinates
var uvs = [
0, 1, // bottom-left
1, 1, // bottom-right
1, 0, // top-right
0, 0 // top-left
]
var indices = [0, 1, 2, 0, 2, 3]
_state.sprite_quad = _make_model_from_arrays(positions, normals, uvs, indices)
return _state.sprite_quad
}
// Draw a 2D sprite at screen position (x, y in 320x240 space)
// opts: { color: [r,g,b,a], mode: "cutout" | "blend" }
function draw_sprite(sprite, x, y, opts) {
if (!sprite || !sprite.texture) return
opts = opts || {}
var color = opts.color || [1, 1, 1, 1]
var mode = opts.mode || "cutout"
// Get sprite dimensions
var w = sprite.width
var h = sprite.height
// Set up orthographic projection for 2D (320x240, origin bottom-left)
push_state()
// Save current camera and set up 2D ortho
camera_ortho(0, 320, 0, 240, -1, 1)
camera_look_at(0, 0, 0, 0, 0, -1, 0, 1, 0)
// Create material for sprite
var alpha_mode = mode == "blend" ? "blend" : "mask"
var mat = make_material("unlit", {
texture: sprite.texture,
color: color,
alpha_mode: alpha_mode,
alpha_cutoff: 0.5,
double_sided: true
})
set_material(mat)
// Create transform for sprite position and scale
var transform = make_transform()
transform.x = x
transform.y = y
transform.z = 0
transform.sx = w
transform.sy = h
transform.sz = 1
transform_mark_dirty(transform)
// Draw the quad
var quad = _get_sprite_quad()
draw_model(quad, transform)
pop_state()
}
// Draw a billboard in 3D space at world position (x, y, z)
// opts: { color: [r,g,b,a], mode: "cutout" | "blend", face: "camera" | "y" }
function draw_billboard(sprite, x, y, z, opts) {
if (!sprite || !sprite.texture) return
opts = opts || {}
var color = opts.color || [1, 1, 1, 1]
var mode = opts.mode || "cutout"
var face = opts.face || "camera"
// Get sprite dimensions (scale to world units - assume 1 pixel = 1/100 world unit)
var scale = 1 / 100
var w = sprite.width * scale
var h = sprite.height * scale
// Create material for billboard (unlit by default)
var alpha_mode = mode == "blend" ? "blend" : "mask"
var mat = make_material("unlit", {
texture: sprite.texture,
color: color,
alpha_mode: alpha_mode,
alpha_cutoff: 0.5,
double_sided: true
})
push_state()
set_material(mat)
// Calculate billboard rotation to face camera
var cam = _state.camera
var dx = cam.x - x
var dy = cam.y - y
var dz = cam.z - z
var yaw = 0
var pitch = 0
if (face == "camera") {
// Full camera-facing billboard
yaw = Math.atan2(dx, dz)
var dist_xz = Math.sqrt(dx * dx + dz * dz)
pitch = Math.atan2(dy, dist_xz)
} else {
// Y-axis aligned billboard (only rotates around Y)
yaw = Math.atan2(dx, dz)
pitch = 0
}
// Build rotation quaternion from yaw and pitch
// First rotate around Y (yaw), then around X (pitch)
var cy = Math.cos(yaw / 2)
var sy = Math.sin(yaw / 2)
var cp = Math.cos(pitch / 2)
var sp = Math.sin(pitch / 2)
// Combine: q_yaw * q_pitch
var qx = cy * sp
var qy = sy * cp
var qz = -sy * sp
var qw = cy * cp
// Create transform
// Anchor is bottom-center, so offset X by -w/2
var transform = make_transform()
transform.x = x
transform.y = y
transform.z = z
transform.qx = qx
transform.qy = qy
transform.qz = qz
transform.qw = qw
transform.sx = w
transform.sy = h
transform.sz = 1
transform_mark_dirty(transform)
// We need to offset the quad so it's centered horizontally
// Create a child transform for the offset
var offset_transform = make_transform()
offset_transform.x = -0.5 // Offset by half width (quad is 0-1, so -0.5 centers it)
offset_transform.y = 0
offset_transform.z = 0
transform_set_parent(offset_transform, transform)
// Draw the quad
var quad = _get_sprite_quad()
draw_model(quad, offset_transform)
pop_state()
}
// ============================================================================
// 8) Animation
// ============================================================================
@@ -1510,9 +2036,7 @@ function seed(seed_int) {
}
function rand() {
// Simple LCG
_state.rng_seed = (_state.rng_seed * 1103515245 + 12345) & 0x7fffffff
return _state.rng_seed / 0x7fffffff
return Math.random()
}
function irand(min_inclusive, max_inclusive) {
@@ -1887,6 +2411,13 @@ function _begin_frame() {
_state._pending_draws = []
_state._clear_color = [0, 0, 0, 1]
_state._clear_depth = true
var ms = mouse.get_state()
if (ms) {
_state.mouse_x = ms.x
_state.mouse_y = ms.y
}
_state.mouse_dx = 0
_state.mouse_dy = 0
}
function _process_events() {
@@ -1895,6 +2426,12 @@ function _process_events() {
if (ev.type == "quit" || ev.type == "window_close_requested") {
return false
}
if (ev.type == "mouse_motion") {
_state.mouse_x = ev.x
_state.mouse_y = ev.y
_state.mouse_dx += ev.xrel
_state.mouse_dy += ev.yrel
}
if (ev.type == "key_down" || ev.type == "key_up") {
var key_name = keyboard.get_key_name(ev.key).toLowerCase()
var pressed = ev.type == "key_down"
@@ -2027,9 +2564,14 @@ return {
make_sphere: make_sphere,
make_cylinder: make_cylinder,
make_plane: make_plane,
make_cone: make_cone,
make_capsule: make_capsule,
make_model: make_model,
load_texture: load_texture,
make_texture: make_texture,
load_sprite: load_sprite,
draw_sprite: draw_sprite,
draw_billboard: draw_billboard,
load_sound: load_sound,
play_sound: play_sound,
stop_sound: stop_sound,
@@ -2052,6 +2594,8 @@ return {
camera_perspective: camera_perspective,
camera_ortho: camera_ortho,
camera_get: camera_get,
screen_ray: screen_ray,
screen_cast_ground: screen_cast_ground,
push_state: push_state,
pop_state: pop_state,

156
examples/billboard_test.ce Normal file
View File

@@ -0,0 +1,156 @@
// Billboard Test for retro3d
// Usage: cell run examples/billboard_test.ce <sprite_path>
// sprite_path should be without extension, e.g. "assets/goblin"
// Creates two rows of billboards going off into the distance like alongside a road
// Colors shift through hues as they get further away
var time_mod = use('time')
var retro3d = use('core')
var sprite_path = args[0]
if (!sprite_path) {
log.console("Usage: cell run examples/billboard_test.ce <sprite_path>")
log.console(" sprite_path: path without extension (e.g. 'assets/goblin')")
$_.stop()
}
var sprite = null
var last_time = 0
var cam_angle = 0
function hsv_to_rgb(h, s, v) {
var c = v * s
var x = c * (1 - Math.abs((h / 60) % 2 - 1))
var m = v - c
var r = 0, g = 0, b = 0
if (h < 60) { r = c; g = x; b = 0 }
else if (h < 120) { r = x; g = c; b = 0 }
else if (h < 180) { r = 0; g = c; b = x }
else if (h < 240) { r = 0; g = x; b = c }
else if (h < 300) { r = x; g = 0; b = c }
else { r = c; g = 0; b = x }
return [r + m, g + m, b + m, 1]
}
function _init() {
log.console("retro3d Billboard Test")
log.console("Loading sprite: " + sprite_path)
retro3d.set_style("ps1")
sprite = retro3d.load_sprite(sprite_path)
if (!sprite) {
log.console("Error: Could not load sprite: " + sprite_path)
$_.stop()
return
}
log.console("Sprite loaded: " + text(sprite.width) + "x" + text(sprite.height))
log.console("Controls:")
log.console(" A/D - Rotate camera")
log.console(" ESC - Exit")
retro3d.set_ambient(0.4, 0.4, 0.5)
retro3d.set_light_dir(0.5, 1.0, 0.3, 1.0, 0.95, 0.9, 1.0)
last_time = time_mod.number()
frame()
}
function _update(dt) {
if (retro3d._state.keys_held['escape']) {
$_.stop()
}
if (retro3d._state.keys_held['a']) {
cam_angle = cam_angle - 1.5 * dt
}
if (retro3d._state.keys_held['d']) {
cam_angle = cam_angle + 1.5 * dt
}
}
function _draw() {
retro3d.clear(0.2, 0.3, 0.4, 1.0)
if (!sprite) return
// Set up perspective camera
retro3d.camera_perspective(60, 0.1, 100)
// Camera orbits around origin
var cam_dist = 8
var cam_x = Math.sin(cam_angle) * cam_dist
var cam_z = Math.cos(cam_angle) * cam_dist
var cam_y = 2
retro3d.camera_look_at(cam_x, cam_y, cam_z, 0, 1, 0)
// Draw ground plane
retro3d.push_state()
var ground_mat = retro3d.make_material("lit", {
color: [0.3, 0.4, 0.3, 1]
})
retro3d.set_material(ground_mat)
var ground = retro3d.make_plane(20, 20)
var ground_transform = retro3d.make_transform()
retro3d.draw_model(ground, ground_transform)
retro3d.pop_state()
// Draw two rows of billboards going into the distance
// Left row at x = -2, right row at x = 2
var num_billboards = 10
var spacing = 2.0
var start_z = -2
for (var i = 0; i < num_billboards; i++) {
var z = start_z - i * spacing
var distance_norm = i / (num_billboards - 1) // 0 to 1
// Hue shifts from 0 (red) to 270 (purple) as distance increases
var hue = distance_norm * 270
var color = hsv_to_rgb(hue, 0.9, 1.0)
// Left row
retro3d.draw_billboard(sprite, -2, 0, z, {
color: color,
mode: "cutout",
face: "y"
})
// Right row (slightly different hue offset)
var hue_right = (hue + 30) % 360
var color_right = hsv_to_rgb(hue_right, 0.9, 1.0)
retro3d.draw_billboard(sprite, 2, 0, z, {
color: color_right,
mode: "cutout",
face: "y"
})
}
}
function frame() {
retro3d._begin_frame()
if (!retro3d._process_events()) {
log.console("Exiting...")
$_.stop()
return
}
var now = time_mod.number()
var dt = now - last_time
last_time = now
_update(dt)
_draw()
retro3d._end_frame()
$_.delay(frame, 1/60)
}
_init()

242
examples/forest.ce Normal file
View File

@@ -0,0 +1,242 @@
// Forest example (Diablo-style camera) for retro3d
// Fixed 3/4 camera, WASD movement, player faces the mouse using a ground screen-cast.
// Press ESC to exit.
var time_mod = use('time')
var retro3d = use('core')
var ground_model = null
var ground_transform = null
var ground_mat = null
var trunk_model = null
var trunk_mat = null
var canopy_model = null
var canopy_mat_a = null
var canopy_mat_b = null
var player_model = null
var player_mat = null
var marker_model = null
var marker_transform = null
var marker_mat = null
var player = {
transform: null,
speed: 6.0,
yaw: 0
}
var trees = []
var num_trees = 80
var cam_offset = { x: 10, y: 12, z: 10 }
var last_time = 0
var last_hit = null
function _set_camera() {
var px = player.transform.x
var pz = player.transform.z
retro3d.camera_perspective(55, 0.1, 300)
retro3d.camera_look_at(
px + cam_offset.x,
cam_offset.y,
pz + cam_offset.z,
px, 0, pz
)
}
function _set_yaw(t, yaw) {
player.yaw = yaw
var half = yaw / 2
retro3d.transform_set_rotation_quat(t, 0, Math.sin(half), 0, Math.cos(half))
}
function _init() {
log.console("retro3d Forest (Diablo camera) Example")
log.console("WASD move")
log.console("Face mouse (ground cast)")
log.console("ESC exit")
retro3d.set_style("ps1")
retro3d.seed(1337)
retro3d.set_ambient(0.35, 0.35, 0.40)
retro3d.set_light_dir(0.4, 1.0, 0.2, 1.0, 0.95, 0.9, 1.0)
retro3d.set_fog(40, 160, [0.55, 0.70, 0.90])
ground_model = retro3d.make_plane(220, 220)
ground_transform = retro3d.make_transform()
retro3d.transform_set_position(ground_transform, 0, 0, 0)
ground_mat = retro3d.make_material("lit", { color: [0.12, 0.22, 0.10, 1.0] })
trunk_model = retro3d.make_cube(1, 1, 1)
canopy_model = retro3d.make_cube(1, 1, 1)
trunk_mat = retro3d.make_material("lit", { color: [0.35, 0.23, 0.14, 1.0] })
canopy_mat_a = retro3d.make_material("lit", { color: [0.11, 0.40, 0.18, 1.0] })
canopy_mat_b = retro3d.make_material("lit", { color: [0.09, 0.33, 0.14, 1.0] })
player_model = retro3d.make_cube(1, 1, 1)
player_mat = retro3d.make_material("lit", { color: [0.85, 0.25, 0.20, 1.0] })
player.transform = retro3d.make_transform()
retro3d.transform_set_position(player.transform, 0, 0.5, 0)
retro3d.transform_set_scale(player.transform, 0.7, 1.0, 0.7)
_set_yaw(player.transform, 0)
marker_model = retro3d.make_cube(1, 1, 1)
marker_mat = retro3d.make_material("unlit", { color: [1.0, 0.95, 0.2, 1.0] })
marker_transform = retro3d.make_transform()
retro3d.transform_set_scale(marker_transform, 0.15, 0.15, 0.15)
for (var i = 0; i < num_trees; i++) {
var x, z
x = (retro3d.rand() - 0.5) * 90
z = (retro3d.rand() - 0.5) * 90
var trunk_h = retro3d.rand() * 3 + 2
var trunk_r = retro3d.rand() * 0.25 + 0.25
var canopy_s = retro3d.rand() * 1.0 + 1.5
var trunk = retro3d.make_transform()
retro3d.transform_set_position(trunk, x, trunk_h / 2, z)
retro3d.transform_set_scale(trunk, trunk_r, trunk_h, trunk_r)
var canopy = retro3d.make_transform()
retro3d.transform_set_position(canopy, x, trunk_h + canopy_s / 2, z)
retro3d.transform_set_scale(canopy, canopy_s, canopy_s, canopy_s)
trees.push({
trunk: trunk,
canopy: canopy,
canopy_mat: retro3d.rand() < 0.5 ? canopy_mat_a : canopy_mat_b
})
}
last_time = time_mod.number()
frame()
}
function _update(dt) {
if (retro3d._state.keys_held['escape']) {
$_.stop()
return
}
_set_camera()
var mx = retro3d._state.mouse_x || 0
var my = retro3d._state.mouse_y || 0
var hit = retro3d.screen_cast_ground(mx, my, 0)
if (hit) {
last_hit = hit
retro3d.transform_set_position(marker_transform, hit.x, 0.05, hit.z)
var px = player.transform.x
var pz = player.transform.z
var dx = hit.x - px
var dz = hit.z - pz
if (dx * dx + dz * dz > 0.00001) {
var yaw = Math.atan2(dx, dz)
_set_yaw(player.transform, yaw)
}
}
var forward = 0
var right = 0
if (retro3d._state.keys_held['w']) forward += 1
if (retro3d._state.keys_held['s']) forward -= 1
if (retro3d._state.keys_held['d']) right -= 1
if (retro3d._state.keys_held['a']) right += 1
if (forward != 0 || right != 0) {
var fx = Math.sin(player.yaw)
var fz = Math.cos(player.yaw)
var rx = Math.cos(player.yaw)
var rz = -Math.sin(player.yaw)
var vx = fx * forward + rx * right
var vz = fz * forward + rz * right
var len = Math.sqrt(vx * vx + vz * vz)
if (len > 0) {
vx /= len
vz /= len
}
var px = player.transform.x + vx * player.speed * dt
var pz = player.transform.z + vz * player.speed * dt
retro3d.transform_set_position(player.transform, px, 0.5, pz)
}
}
function _draw() {
retro3d.clear(0.55, 0.70, 0.90, 1.0)
_set_camera()
retro3d.push_state()
retro3d.set_material(ground_mat)
retro3d.draw_model(ground_model, ground_transform)
retro3d.pop_state()
retro3d.push_state()
retro3d.set_material(trunk_mat)
for (let i = 0; i < trees.length; i++) {
retro3d.draw_model(trunk_model, trees[i].trunk)
}
retro3d.pop_state()
retro3d.push_state()
for (let i = 0; i < trees.length; i++) {
retro3d.set_material(trees[i].canopy_mat)
retro3d.draw_model(canopy_model, trees[i].canopy)
}
retro3d.pop_state()
retro3d.push_state()
retro3d.set_material(player_mat)
retro3d.draw_model(player_model, player.transform)
retro3d.pop_state()
if (last_hit) {
retro3d.push_state()
retro3d.set_material(marker_mat)
retro3d.draw_model(marker_model, marker_transform)
retro3d.pop_state()
}
}
function frame() {
// Begin frame
retro3d._begin_frame()
// Process events
if (!retro3d._process_events()) {
log.console("Exiting...")
$_.stop()
return
}
// Calculate delta time
var now = time_mod.number()
var dt = now - last_time
last_time = now
// Update
_update(dt)
// Draw
_draw()
// End frame (submit GPU commands)
retro3d._end_frame()
// Schedule next frame
$_.delay(frame, 1/60)
}
// Start
_init()

117
examples/sprite_test.ce Normal file
View File

@@ -0,0 +1,117 @@
// Sprite Test for retro3d
// Usage: cell run examples/sprite_test.ce <sprite_path>
// sprite_path should be without extension, e.g. "assets/goblin"
// Fills screen with sprites, hue-shifted based on normalized screen position
var time_mod = use('time')
var retro3d = use('core')
var sprite_path = args[0]
if (!sprite_path) {
log.console("Usage: cell run examples/sprite_test.ce <sprite_path>")
log.console(" sprite_path: path without extension (e.g. 'assets/goblin')")
$_.stop()
}
var sprite = null
var last_time = 0
function hsv_to_rgb(h, s, v) {
var c = v * s
var x = c * (1 - Math.abs((h / 60) % 2 - 1))
var m = v - c
var r = 0, g = 0, b = 0
if (h < 60) { r = c; g = x; b = 0 }
else if (h < 120) { r = x; g = c; b = 0 }
else if (h < 180) { r = 0; g = c; b = x }
else if (h < 240) { r = 0; g = x; b = c }
else if (h < 300) { r = x; g = 0; b = c }
else { r = c; g = 0; b = x }
return [r + m, g + m, b + m, 1]
}
function _init() {
log.console("retro3d Sprite Test")
log.console("Loading sprite: " + sprite_path)
retro3d.set_style("ps1")
sprite = retro3d.load_sprite(sprite_path)
if (!sprite) {
log.console("Error: Could not load sprite: " + sprite_path)
$_.stop()
return
}
log.console("Sprite loaded: " + text(sprite.width) + "x" + text(sprite.height))
log.console("Press ESC to exit")
last_time = time_mod.number()
frame()
}
function _update(dt) {
if (retro3d._state.keys_held['escape']) {
$_.stop()
}
}
function _draw() {
retro3d.clear(0.1, 0.1, 0.15, 1.0)
if (!sprite) return
var w = sprite.width
var h = sprite.height
// Fill from bottom to top with sprites
// Hue shifts based on normalized screen position
var y = 0
while (y < 240) {
var x = 0
while (x < 320) {
// Normalized position (0-1)
var nx = x / 320
var ny = y / 240
// Hue based on position (0-360 degrees)
var hue = (nx + ny) * 180 // Combined X and Y influence
hue = hue % 360
var color = hsv_to_rgb(hue, 0.8, 1.0)
retro3d.draw_sprite(sprite, x, y, {
color: color,
mode: "cutout"
})
x = x + w
}
y = y + h
}
}
function frame() {
retro3d._begin_frame()
if (!retro3d._process_events()) {
log.console("Exiting...")
$_.stop()
return
}
var now = time_mod.number()
var dt = now - last_time
last_time = now
_update(dt)
_draw()
retro3d._end_frame()
$_.delay(frame, 1/60)
}
_init()

114
lance3d.md Normal file
View File

@@ -0,0 +1,114 @@
## Core API
Get time since game boot, in seconds
time()
Get runtime statistics
stat(name: "draw_calls" | "triangles" | "fps" | "memory_bytes")
log(...args: any)
set_lighting({
sun_dir: [0.3,-1,0.2], // normalized internally
sun_color: [1,1,1], // rgb
ambient: [0.25,0.25,0.25] // rgb
})
set_fog({
enabled: false,
color: [0.5,0.6,0.7],
near: 10,
far: 80
})
// lance3d material
{
color_map: texture | null, // optional
paint: color, // required (default: white)
coverage: "opaque" | "cutoff" | "blend",
face: "single" | "double",
lamp: "lit" | "unlit"
}
## Draw API
Loads a gltf model, returning an array of {mesh, material}, with materials compressed into a render3d material
load_model(path) -> array<{mesh, material}>
make_cube(w, h, d) -> mesh
make_sphere(r, segments=12) -> mesh
make_cylinder(r, h, segments=12) -> mesh
make_plane(w, h) -> mesh
Generates a pose that can be applied to a model
sample_pose(model, name, time)
Draws a model with a given base transform
draw_model(model, transform=null, pose=null)
Draws a mesh with a material
draw_mesh(mesh, transform=null, material=null)
load_sound(path) -> sound | null
play_sound(sound, opts={
volume: 1.0, // 0..1
pitch: 1.0, // 1.0 = normal
pan: 0.0, // -1 = left, 0 = center, 1 = right
loop: false // boolean
}) -> voice
stop_sound(voice)
// when drawing, a provided material will override defaults for the system
load_texture(path) -> texture
draw_billboard(texture, x, y, z, size=1.0, mat=null)
draw_sprite(texture, x, y, size=1.0, mat=null)
## Camera API
camera_look_at(
ex, ey, ez, // eye
tx, ty, tz, // target
upx = 0, upy = 1, upz = 0
)
camera_perspective(fov_deg=60, near=0.1, far=1000)
camera_ortho(left, right, bottom, top, near=-1, far=1)
## Collision API
retro3d.add_collider_sphere(transform, radius, opts={layer_mask:1, user:null}) -> collider
retro3d.add_collider_box(transform, sx, sy, sz, opts={layer_mask:1, user:null}) -> collider
retro3d.set_collider_transform(collider, transform)
retro3d.set_collider_layer(collider, layer_mask)
retro3d.overlaps(layer_mask_a=null, layer_mask_b=null) -> array<{a: collider, b: collider}>
retro3d.raycast(ox,oy,oz, dx,dy,dz, opts={
max_dist: Infinity,
layer_mask: 0xFFFFFFFF
}) -> null | {x,y,z, nx,ny,nz, distance, collider}
retro3d.remove_collider(collider)
## Input
btn(id, player=0) -> bool // held
btnp(id, player=0) -> bool // pressed this frame
## Math
seed(seed_int)
rand() -> number // [0,1)
irand(min_inclusive, max_inclusive) -> int
## Debug API
vertex {x, y, z, u=0, v=0, color}
point(vertex, size=1.0)
line(vertex_a, vertex_b, width=1.0)
triangle(vertex_a, vertex_b, vertex_c, mat=null)
ray(ox,oy,oz, dx,dy,dz, len, opts)
grid(size, step, norm = {x:0,y:1,z:0}, color)
aabb(cx,cy,cz, ex,ey,ez, opts) // center+extents
obb(transform, sx,sy,sz, opts) // oriented via transform
frustum(camera_state_or_params, opts)
text3d(str, x,y,z, opts={size_px:12, depth:"always"|"test"})
Render a specific collider
collider(collider, color)