Files
retro3d/website/manual.md
2025-12-21 10:39:05 -06:00

567 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Lance 3D Manual (v1)
Lance 3D is a focused 3D fantasy-console engine for making games with a PS1 / N64 / Saturn kind of visual personality. Its immediate-mode: you load some assets, set a camera, draw every frame, ship.
Game code is written in **CellScript** (not JavaScript):
```text
https://cell-lang.org/cellscript/
```
Networking is **v2**.
---
## Specifications
* **Target FPS:** 60
* **VSync:** always on
* **Internal render size:** 320×240 (always)
* **Windowing:** letterboxed into whatever window size you choose
* **Screen coordinates:** origin at **bottom-left**
* Pixel addressable range: **x: 0..319**, **y: 0..239**
* Floats are allowed (subpixel placement), but the result is still “pixel-ish”
* **3D coordinates:** right-handed, **Z-up**
* **Units:** 1 unit = 1 meter
* **Angles:** radians (`pi` exists)
Assets:
* Models: `.glb`
* Textures: `.png` / `.jpg`
* Sounds: `.wav` / `.mp3` (converted to 16-bit PCM @ 44.1 kHz)
* Music: `.mid` / `.midi` via the embedded chip
Voices (maximum simultaneously):
* PS1: **24**
* N64: **24**
* Saturn: **32**
Voice cap includes *everything*: `play_sound()` voices + voices produced by the MIDI/chip.
Hot reload:
* The engine watches and reloads changed **models** and **textures** during development.
---
## The big rule: immediate mode
Lance 3D does not keep a “scene.” It draws what you ask it to draw **this frame**.
If you want:
* a scene tree → you build it
* a global “current material” → you build it
* an entity system → you build it
Lance 3D mostly retains **assets**, not **render intent**.
### What resets each frame
At the start of each `draw()` call, Lance 3D resets:
* camera
* lighting
* fog
* 2D clip/scissor
So if you want those, you set them in `draw()`.
---
## Getting started
A Lance 3D “game” is a folder. Asset paths are **absolute-from-game-root**, and you **omit extensions**.
* `"man/model"` loads `"man/model.glb"`
* `"tex/brick"` loads `"tex/brick.png"` or `"tex/brick.jpg"`
* `"sfx/hit"` loads `"sfx/hit.wav"` or `"sfx/hit.mp3"`
Top-level code runs once at startup: load assets, create meshes, initialize state.
### The main loop
You dont write the loop. You register callbacks.
```cell
var t = 0
register({
update(dt) { t += dt },
draw() { /* draw every frame */ }
})
```
* `update(dt)` once per frame, `dt` in seconds
* `draw()` once per frame
---
## Hello cube
```cell
def cube = make_cube(1, 1, 1)
def tex = load_texture("tex/brick")
def mat = {
color_map: tex,
paint: [1, 1, 1, 1],
coverage: "opaque",
face: "single",
lamp: "lit"
}
var a = 0
register({
update(dt) { a += dt },
draw() {
set_style("ps1")
camera_perspective(pi / 3, 0.1, 100)
camera_look_at(3, 3, 2, 0, 0, 0)
set_lighting({
sun_dir: [0.3, -1, 0.2],
sun_color: [1, 1, 1],
ambient: [0.25, 0.25, 0.25]
})
def trs = trs_matrix(0, 0, 0, 0, 0, a, 1, 1, 1)
draw_mesh(cube, trs, mat)
}
})
```
If you see loud purple, you drew without a material. Thats intentional.
---
## Time, logging, stats
> **`time()` → number**
> Seconds since boot.
> **`log(...args)` → void**
> Print to the log.
> **`stat(name)` → number**
> `name`: `"draw_calls" | "triangles" | "fps" | "memory_bytes"`
> Throws if unknown.
---
## Style (PS1 / N64 / Saturn)
Style is global and persists until changed.
> **`set_style(style = "ps1")` → void**
> `style`: `"ps1" | "n64" | "saturn"`
Triangle limits are soft: warnings, not failures.
### Texture & model tiers (low / normal / hero)
Textures and models are treated as belonging to a tier and interpreted through the current style.
| Style | Low | Normal | Hero |
| ------ | --- | ------ | ---- |
| PS1 | 64 | 128 | 256 |
| N64 | — | 32 | 64 |
| Saturn | 32 | 64 | 128 |
Defaults:
* **Textures:** `normal`
* **Models:** `normal`
You can tag assets:
> **`tag_texture(texture, tier = "normal")` → void**
> **`tag_model(model, tier = "normal")` → void**
> `tier`: `"low" | "normal" | "hero"`
Tags are hints to the style system (budget + interpretation). They dont invent LODs for you.
---
## Environment (camera, lighting, fog)
These reset each `draw()`. Set them in `draw()`.
### Camera
> **`camera_look_at(ex, ey, ez, tx, ty, tz, upx = 0, upy = 0, upz = 1)` → void**
> **`camera_perspective(fov = pi/3, near = 0.1, far = 1000)` → void**
> FOV is radians. Throws if planes are invalid.
> **`camera_ortho(left, right, bottom, top, near = -1, far = 1)` → void**
### Lighting
> **`set_lighting({ sun_dir, sun_color, ambient })` → void**
> All colors are 0..1. `sun_dir` is normalized internally.
### Fog
> **`set_fog({ enabled=false, color, near, far })` → void**
> Fog is a first-class style tool.
---
## Transforms
Transforms are opaque TRS blobs created by `trs_matrix(...)`. Functions accept a transform blob or `null` for identity.
> **`trs_matrix(tx, ty, tz, rx, ry, rz, sx = 1, sy = 1, sz = 1)` → transform**
* translation in meters
* rotation is Euler **radians**, applied in **XYZ order**
* scale is unitless multiplier
(If you want quaternions, build your own helper and still feed the resulting matrix/blob shape the engine expects.)
---
## Materials
Materials are plain objects with a fixed shape. No shaders.
```cell
def brick = {
color_map: load_texture("tex/brick"),
paint: [1, 1, 1, 1],
coverage: "opaque",
face: "single",
lamp: "lit"
}
```
Fields:
* `color_map: texture | null`
* `paint: [r,g,b,a]` (0..1)
* `coverage: "opaque" | "cutoff" | "blend"`
* `"cutoff"` uses alpha threshold **0.5**
* `face: "single" | "double"`
* Front faces are **clockwise (CW)**
* `lamp: "lit" | "unlit"`
Transparency:
* `"blend"` draws are depth-sorted back-to-front (simple, stable, not perfect).
---
## Assets and paths
Loads are synchronous. Missing assets return `null` and log details.
> **`load_texture(path)` → texture | null**
> **`load_model(path)` → model | null**
> **`load_sound(path)` → sound | null**
> **`load_font(path)` → font | null**
Asset handles are stable by path:
* calling `load_texture("tex/brick")` again returns the same handle
* hot reload updates the content behind that handle in development
---
## Drawing order
1. 3D world (meshes, models, billboards)
2. 2D overlay (sprites, text, 2D primitives)
**2D always draws over 3D.**
To get “sprites in 3D,” use **billboards**.
---
## Drawing 3D
> **`draw_model(model, transform = null, pose = null)` → void**
> Uses embedded model materials.
> **`draw_mesh(mesh, transform = null, material = null)` → void**
> If `material=null`, uses the missing-material purple.
---
## Billboards (world space)
Billboards face the camera and are anchored at **bottom-center**.
> **`draw_billboard(texture, x, y, z, size = 1.0, material = null)` → void**
`size` is in **meters** (height). Aspect ratio comes from the texture.
---
## 2D drawing (PICO-8 style)
Everything here is screen space (320×240), anchored at bottom-left, drawn over 3D.
### Sprites
> **`draw_sprite(texture, x, y, size = 1.0, material = null)` → void**
`size` is a pixel scale:
* `size=1.0` draws at the textures authored pixel size in 320×240 space
* `size=2.0` doubles it, etc.
### Text
Lance 3D ships with a built-in default font. You can also pass a font per draw.
> **`draw_text(text, x, y, scale = 1.0, color = [1,1,1,1], font = null)` → void**
* `\n` creates a new line
* unknown glyphs render as `?`
* `font=null` uses the built-in font
### Simple primitives
> **`pset(x, y, color = [1,1,1,1])` → void**
> **`line2(x0, y0, x1, y1, color = [1,1,1,1])` → void**
> **`rect(x, y, w, h, color = [1,1,1,1])` → void**
> **`rectfill(x, y, w, h, color = [1,1,1,1])` → void**
> **`circ(x, y, r, color = [1,1,1,1])` → void**
> **`circfill(x, y, r, color = [1,1,1,1])` → void**
> **`clip(x, y, w, h)` → void** (enable)
> **`clip()` → void** (disable; also resets each `draw()`)
---
## Procedural geometry
> **`make_cube(w = 1, h = 1, d = 1)` → mesh**
> **`make_sphere(r = 1, segments = 12)` → mesh**
> **`make_cylinder(r = 1, h = 1, segments = 12)` → mesh**
> **`make_plane(w = 1, h = 1)` → mesh**
---
## Animation
> **`anim_info(model)` → any**
> Structured info about available animations.
> **`sample_pose(model, name, t)` → pose | null**
> `t` is seconds. Nothing advances unless you advance it.
---
## Audio
Stereo output. Converted to 16-bit PCM @ 44.1kHz.
> **`play_sound(sound, { volume=1, pitch=1, pan=0, loop=false })` → voice**
> **`stop_sound(voice)` → void**
Embedded chip / MIDI:
> **`chip(channel, op, value)` → void**
> Examples:
* `chip(0, "play_midi", "music/theme")`
* `chip(0, "volume", 120)`
* `chip(2, "mute", 1)`
Voice stealing (when cap is hit) is deterministic:
* lowest priority loses first (chip voices are lowest by default)
* ties: oldest voice loses
---
## Collision (v1: simple queries)
Collision in v1 is **stateless queries**. No collider world, no layers, no retained physics state.
### Types
Use plain objects:
```cell
def ray = { origin: [ox, oy, oz], dir: [dx, dy, dz] } // dir should be normalized
def box = { min: [minx, miny, minz], max: [maxx, maxy, maxz] } // AABB
```
Ray hits return `hit | null`:
```cell
// when hit:
{ dist: number, point: [x,y,z], normal: [nx,ny,nz] }
// when miss:
null
```
### Shape checks
> **`check_collision_spheres(c1, r1, c2, r2)` → bool**
> **`check_collision_boxes(box1, box2)` → bool**
> **`check_collision_box_sphere(box, center, radius)` → bool**
### Ray queries
> **`get_ray_collision_sphere(ray, center, radius)` → hit | null**
> **`get_ray_collision_box(ray, box)` → hit | null**
> **`get_ray_collision_mesh(ray, mesh, transform = null)` → hit | null**
### Mesh collider (cached acceleration)
For repeated raycasts (picking, bullets, LOS checks), you can precompute a mesh collider once:
> **`make_mesh_collider(mesh)` → mesh_collider**
This builds an internal acceleration structure (BVH) in **mesh local space**.
Then use:
> **`get_ray_collision_mesh_collider(ray, mesh_collider, transform = null)` → hit | null**
Same result as `get_ray_collision_mesh`, but faster for repeated queries.
### Will this work?
Yes—this is a solid “fantasy-console collision” model **if you keep the expectations correct**:
* Great for: triggers, overlap tests, ray picking, hitscan weapons, simple AI line-of-sight.
* Not a physics engine: no solver, no continuous collision handling, no automatic contact resolution.
Mesh colliders specifically:
* Great when the mesh is **static** (level geometry) or the mesh itself doesnt change.
* Fine for moving instances: pass a different `transform` per query.
* If the mesh deforms/changes topology, you must rebuild the collider (call `make_mesh_collider` again).
---
## Input
> **`btn(id, player = 0)` → bool**
> **`btnp(id, player = 0)` → bool**
Button IDs:
* 0 left, 1 right, 2 down, 3 up
* 4 A, 5 B, 6 X, 7 Y
* 8 L, 9 R
* 10 Start, 11 Select
---
## Debug drawing (world space)
> **`dbg_line(a, b, color = [1,0,0])` → void**
> **`dbg_aabb(box, color = [1,0,0])` → void**
> **`dbg_ray(ray, color = [1,0,0])` → void**
---
# Cheat sheet (v1 API)
## Core
* `register({ update(dt), draw() })` // start the game loop
* `time()` // seconds since boot
* `log(...args)` // print to log
* `stat(name)` // runtime stats: draw_calls/triangles/fps/memory_bytes
## Style
* `set_style(style = "ps1")` // set visual style
* `tag_texture(texture, tier = "normal")` // tag texture as low/normal/hero
* `tag_model(model, tier = "normal")` // tag model as low/normal/hero
## Environment
* `camera_look_at(ex, ey, ez, tx, ty, tz, upx=0, upy=0, upz=1)` // set view
* `camera_perspective(fov=pi/3, near=0.1, far=1000)` // set perspective projection
* `camera_ortho(left, right, bottom, top, near=-1, far=1)` // set ortho projection
* `set_lighting({ sun_dir, sun_color, ambient })` // set sun + ambient
* `set_fog({ enabled=false, color, near, far })` // set fog
## Transforms
* `trs_matrix(tx, ty, tz, rx, ry, rz, sx=1, sy=1, sz=1)` // build transform blob (TRS)
## Assets
* `load_texture(path)` // load png/jpg, returns texture or null
* `load_model(path)` // load glb, returns model or null
* `load_sound(path)` // load wav/mp3, returns sound or null
* `load_font(path)` // load a font, returns font or null
## Procedural geometry
* `make_cube(w=1, h=1, d=1)` // cube mesh
* `make_sphere(r=1, segments=12)` // sphere mesh
* `make_cylinder(r=1, h=1, segments=12)` // cylinder mesh
* `make_plane(w=1, h=1)` // plane mesh
## Drawing 3D
* `draw_model(model, transform=null, pose=null)` // draw model with embedded materials
* `draw_mesh(mesh, transform=null, material=null)` // draw mesh with optional material override
* `draw_billboard(texture, x, y, z, size=1.0, material=null)` // camera-facing sprite in world
## Drawing 2D
* `draw_sprite(texture, x, y, size=1.0, material=null)` // screen-space sprite over 3D
* `draw_text(text, x, y, scale=1.0, color=[1,1,1,1], font=null)` // screen-space text
* `pset(x, y, color=[1,1,1,1])` // set pixel
* `line2(x0, y0, x1, y1, color=[1,1,1,1])` // draw line
* `rect(x, y, w, h, color=[1,1,1,1])` // rectangle outline
* `rectfill(x, y, w, h, color=[1,1,1,1])` // filled rectangle
* `circ(x, y, r, color=[1,1,1,1])` // circle outline
* `circfill(x, y, r, color=[1,1,1,1])` // filled circle
* `clip(x, y, w, h)` // enable 2D clipping
* `clip()` // disable 2D clipping
## Animation
* `anim_info(model)` // inspect available animations
* `sample_pose(model, name, t)` // sample pose at time t, returns pose or null
## Audio
* `play_sound(sound, { volume=1, pitch=1, pan=0, loop=false })` // play a sound voice
* `stop_sound(voice)` // stop a playing voice
* `chip(channel, op, value)` // control embedded chip / MIDI
## Collision
* `check_collision_spheres(c1, r1, c2, r2)` // sphere-sphere overlap
* `check_collision_boxes(box1, box2)` // AABB-AABB overlap
* `check_collision_box_sphere(box, center, radius)` // AABB-sphere overlap
* `get_ray_collision_sphere(ray, center, radius)` // ray vs sphere, returns hit or null
* `get_ray_collision_box(ray, box)` // ray vs AABB, returns hit or null
* `get_ray_collision_mesh(ray, mesh, transform=null)` // ray vs mesh, returns hit or null
* `make_mesh_collider(mesh)` // build cached collider (BVH) from mesh
* `get_ray_collision_mesh_collider(ray, mesh_collider, transform=null)` // fast ray vs mesh collider
## Input
* `btn(id, player=0)` // button held
* `btnp(id, player=0)` // button pressed this frame
## Debug
* `dbg_line(a, b, color=[1,0,0])` // debug line
* `dbg_aabb(box, color=[1,0,0])` // debug AABB
* `dbg_ray(ray, color=[1,0,0])` // debug ray