refactor: move world concepts into engine

This commit is contained in:
Toad 2026-08-24 15:17:53 +02:00
parent eeedcb8e48
commit 2d15c7ab8d
52 changed files with 3298 additions and 1558 deletions

View file

@ -1,137 +0,0 @@
# Jerklint Review 01
Reviewed 2026-08-04. Scope: handwritten source under `engine/`, `app/`,
`server/`, `shared/`, and `scripts/`; root runtime, build, lint, editor, and
project configuration; `README.md`. Generated assets, build output,
dependencies, and lockfile were excluded.
**Jerklint Findings**
1. **High** `engine/render/Color.ts:37-43`,
`engine/render/Texture.ts:46-56`, `engine/render/Rasterizer.ts:199-203`,
`engine/render/RenderConfig.ts:104-114`, `scripts/gen-assets.ts:136-143`:
`Color.lerp` promises to interpolate a `Color` but omits alpha, so `Color.rgb`
silently supplies `255`. Every bilinear sample therefore becomes opaque.
The `clean` preset enables linear filtering, while the NPC asset has a
transparent background and alpha cutout happens after sampling. This is a
false generic abstraction with a direct behavior change hidden behind an
unrelated render knob. Smallest fix: interpolate alpha in `Color.lerp`; add
an explicitly named RGB-only blend only if fog or sky ever needs one.
2. **High** `engine/render/RenderConfig.ts:16-58`,
`engine/render/RenderConfig.ts:64-115`, `app/main.ts:19-29`,
`engine/render/Framebuffer.ts:43-73`: live render configuration has no
authoritative validity or application boundary. Public presets are mutable
singleton objects, `useConfig` retains the supplied alias, dimensions are
snapshotted into framebuffer resources, and documented numeric ranges are
not enforced. A direct width change leaves resources stale; values such as
`colorDepth = 0` make quantization divide by zero. This will become immediate
pressure when planned live sliders arrive. Smallest fix: make presets
readonly templates, create a fresh validated config for each update, and
route updates through one app-side apply function that rebuilds resources
when dimensions change.
4. **Medium** `app/level.ts:18-27`, `app/level.ts:66-105`,
`app/player.ts:2`, `app/player.ts:19-31`, `app/player.ts:61-97`,
`app/main.ts:48-52`: collision policy has no single owner. `Player.update`
accepts the render-heavy `Level`, then reconstructs gameplay facts using a
hardcoded ground height and NPC radius; NPC visual size lives in `main.ts`.
`buildLevel` also constructs geometry, collision, NPC placement, and sky in
one body. NPC or floor changes therefore require cross-file agreement and
isolated player tests need unrelated render data. Smallest fix: let `Level`
own a compact NPC descriptor and a narrow collision world containing ground,
boxes, and circles; pass only that collision value to `Player.update`.
5. **Medium** `app/main.ts:54-69`, `app/player.ts:23-55`: browser key codes are
the player simulation API. Physics owns WASD and Space bindings, receives a
mutable `Set<string>`, cannot represent a press edge, and therefore treats a
held jump key as a fresh jump whenever landing. Lost `keyup` events also
leave state stuck because no blur path clears the set. Rebinding, gamepad
input, and deterministic tests all require physics edits. Smallest fix: map
browser events in `main.ts` to a semantic `PlayerInput` value with movement
axes and `jumpPressed`; clear raw input on blur.
6. **Medium** `engine/scene/Camera.ts:27-33`,
`engine/math/Mat4.ts:23-34`, `engine/render/Rasterizer.ts:9-19`,
`engine/render/Rasterizer.ts:58-68`, `engine/render/Rasterizer.ts:77-115`:
projection limits and clipping have separate authorities. Camera projection
declares near/far values of `0.05` and `100`, but `Rasterizer.project`
discards clip-space `z` and clips only against unrelated `w >= 0.01`; far
clipping is absent. `Rasterizer.draw` looks matrix-generic while relying on
perspective-specific `w` semantics for clipping, depth, and fog. Smallest
fix: retain clip-space `z` and clip against canonical near/far planes, or
narrow and name the API so its perspective-matrix contract is explicit.
7. **Medium** `engine/render/RenderConfig.ts:48-54`,
`engine/render/Texture.ts:36-38`, `engine/render/Rasterizer.ts:51-57`,
`engine/render/Sky.ts:34-36`, `engine/render/Sky.ts:93-98`,
`engine/render/Sky.ts:121-128`: closed option unions fail open. New texture
filters silently become nearest, new lighting modes silently become unlit,
and new cloud kinds silently become basic because each dispatcher uses a
binary predicate plus fallback. `basicCumulus` accepts the full `CloudLayer`
union, preventing TypeScript from exposing the missing branch. Smallest fix:
use exhaustive switches with a `never` check and narrow cloud helpers to
their concrete variant types.
11. **Low** `engine/scene/Sprite.ts:9-23`, `app/main.ts:48-52`,
`app/main.ts:107-112`: `Sprite.texture` is assigned but never read; drawing
separately reaches back to `textures.npc`. Two sources can diverge, and the
type promises ownership the render path ignores. Smallest fix: draw with
`npc.texture`, or remove the field if material binding is intentionally
external.
12. **Low** `engine/scene/Camera.ts:4-13`, `engine/math/Mat4.ts:37-43`,
`engine/render/Sky.ts:62-66`, `app/main.ts:73-79`: camera pitch validity is
documented by `Camera` but enforced by one caller through the unexplained
literal `1.4`. Both camera and sky basis construction rely on that ritual.
Any second camera producer can create a degenerate basis. Smallest fix: own
the pitch limit and look-delta/clamp operation in the `Camera` namespace.
14. **Low** `engine/math/Vec3.ts:3-4`, `engine/math/Vec3.ts:34-37`:
`Vec3.normalize` violates its namespace-wide fresh-result guarantee only for
zero vectors by returning the mutable input alias. Callers can safely mutate
ordinary results but unexpectedly mutate source state at one edge. Smallest
fix: return a fresh zero vector.
**Scorecard**
- DRY: concern - collision, release, and resource-update policy have split owners.
- KISS: pass - core engine stays direct, data-oriented, and framework-free.
- YAGNI: concern - two unused public placeholders remain.
- SOC: concern - render config application and release artifacts mix concerns.
- Cohesion: concern - player physics consumes unrelated level rendering data.
- Coupling: fail - config/resources and projection/clipping rely on hidden cross-module rules.
- Dependency Direction: pass - browser code points inward and engine remains DOM-free.
- Law of Demeter: pass - shallow plain-data access; no traversal chains or service locators.
- Immutability: concern - mutable preset aliases and zero-vector aliasing weaken boundaries.
- Declarative Shape: concern - option unions and release phases are not exhaustively interpreted.
- Implicit Contracts: fail - alpha, dimensions, pitch, clipping, checks, and publishing depend on rituals.
- Abstraction Pressure: concern - important rules are under-centralized while stale exports remain.
- Naming/API Clarity: fail - `Color.lerp`, `check`, `publish`, and `Sprite.texture` overpromise.
- Locality: concern - NPC behavior and configuration changes require cross-file coordination.
- Testability: fail - zero tests, false-green test command, and broad player fixtures.
- File/API Shape: concern - type/namespace pattern is strong; ignored and stale exports weaken it.
- Predicate Accuracy: concern - binary fallback dispatch silently accepts future union variants.
- Construction Phase Separation: concern - level and release construction each hide multiple phases.
**Strengths**
- Engine/browser dependency boundary is clean and enforced by separate TypeScript libraries.
- Domain types generally own behavior through matching namespaces and matching filenames.
- Hot-loop mutation is local, explicit, and appropriate for a software rasterizer.
- Rasterizer, sky, texture loading, and procedural mesh generation remain cohesive despite numeric code.
- `main.ts` is still a reasonable composition root; splitting presentation or FPS code now would add ceremony.
- No ECS, service locator, class hierarchy, utility bag, dependency cycle, or speculative plugin system appeared.
**Validation**
- `bun run build`: passed.
- `bunx tsc --build --dry`: selected engine, app, and config only.
- `bun run lint`: exited with one `typescript(array-type)` warning at `scripts/gen-assets.ts:162`.
- `bun run test`: found zero tests and emitted Bun's tsconfig directory-mismatch internal error; `--pass-with-no-tests` kept the command green.
**Verdict**
Refactor soon. Keep the core architecture. Fix lying primitives and quality
commands first, then centralize configuration, input, and collision contracts.

714
.agents/plans/gpu.md Normal file
View file

@ -0,0 +1,714 @@
# GPU renderer migration direction note
Status: **options open; migration seam agreed.** This note records the current
problem, viable approaches, expected scaling, proposed architecture, migration
order, and decisions still needed. It is not approval to delete the software
renderer or to commit to WebGL2, WebGPU rasterization, or WebGPU compute.
## Goal
Move triangle transformation and pixel rasterization off the CPU so adding more
world geometry and moving actors does not collapse frame rate.
The GPU path must preserve the configurable PS1 look rather than replace it with
a fixed visual preset. `RenderConfig` remains the source of live settings for
internal resolution, vertex snap, texture filtering, lighting, fog, color depth,
dither, and LOD distance.
The current software renderer remains playable during migration. A GPU backend
is added beside it and selected at startup or by a debug option until parity and
performance are proven.
## Decisions already made
- Build a parallel backend instead of replacing the software renderer in place.
- Keep engine world/content boundaries unchanged. GPU work must not reintroduce
content kinds, registries, or renderer knowledge of frogs, trees, texture names,
or other game content.
- Keep `RenderScene` as the clone-safe, content-agnostic render projection of a
live `Level`.
- Keep main-thread frustum culling and scene-local render prototype indexes at
first. GPU culling is not required to obtain the expected performance gain.
- Optimize for scaling as content grows, not merely for improving the current
benchmark by a small constant factor.
- Preserve low-resolution rendering and the live PS1 controls.
## Decisions not made
- WebGPU versus WebGL2 as the first GPU backend.
- Fixed-function GPU rasterization versus a custom WebGPU compute rasterizer.
- Whether WebGL2 and WebGPU should both exist long-term.
- Whether the software renderer remains a permanent fallback after a GPU backend
becomes default.
- Whether GPU output must be pixel-identical to software output or only visually
equivalent under the same `RenderConfig`.
- Required browser, OS, and device support.
- Whether GPU timing statistics are required for the first usable backend.
## Current baseline
Current final browser benchmark at 384x216:
| Backend | Work median | Work p95 | Frame p95 |
| --- | ---: | ---: | ---: |
| Software, single thread | 30.07 ms | 32.19 ms | 32.60 ms |
| Software, 3 workers | 11.16 ms | 13.35 ms | 16.88 ms |
These numbers are machine- and scene-specific. They are useful as the baseline
for this repository, not as universal predictions.
The software cost grows with transformed triangles, covered pixels, and texture
samples. Workers divide framebuffer rows, but every worker still loops over the
same visible draw groups and transforms the same triangles for its band. More
content therefore keeps increasing CPU work even when screen resolution stays
fixed.
## Remaining implementation options
### Option A: standard WebGPU rasterization
Use WebGPU render pipelines, vertex/index buffers, textures, samplers, a depth
texture, actor instancing, and WGSL shaders. Recreate PS1 traits in shaders and a
post-process pass.
Strengths:
- Uses hardware raster units for the work they are designed to perform.
- Best route to large geometry and actor-count headroom.
- Explicit resource and command model fits immutable compiled `RenderScene` data.
- Storage buffers and compute remain available later without changing API.
- Device-loss handling can fall back to the software backend.
Costs:
- More setup and resource-lifecycle code than WebGL2.
- Uniform/storage alignment and bind-group layout require care.
- Texture upload row pitch must satisfy WebGPU alignment requirements.
- Browser/device support must be checked against actual release targets.
- GPU work timing needs optional timestamp queries or coarse CPU submission timing.
Expected scaling:
Current geometry should normally fit in low single-digit GPU milliseconds on a
reasonable desktop GPU. At this low internal resolution, 50k triangles is small
for fixed-function GPU rasterization. Hundreds of thousands of visible triangles
should remain practical if draw count, state changes, and overdraw stay controlled.
These are estimates, not acceptance results.
### Option B: standard WebGL2 rasterization
Use a WebGL2 context, VAOs, vertex/index buffers, textures, samplers, an offscreen
framebuffer, a depth attachment, GLSL ES 3 shaders, and instanced actor draws.
Strengths:
- Simpler first standard-raster backend.
- Broad and mature browser implementation history.
- All current PS1 effects can be implemented without compute shaders.
- Hardware triangle throughput should be enough for this engine by a wide margin.
- Easier shader/program debugging in many browser tools.
Costs:
- More implicit global state than WebGPU.
- Resource binding and synchronization are less explicit.
- No general compute path if exact software rasterization becomes necessary.
- Large dynamic instance data is less flexible than WebGPU storage buffers.
- A later WebGPU backend would duplicate substantial platform code.
Expected scaling:
For this renderer, standard WebGL2 and standard WebGPU should have similar basic
triangle throughput because both use hardware rasterization. JavaScript draw-call
overhead, material changes, and actor submission are more likely to distinguish
them than raw fill rate at 384x216.
### Option C: WebGPU compute software rasterizer
Port the custom rasterizer to WGSL compute rather than using render pipelines.
This is the path with the best chance of preserving exact custom edge, depth, and
pixel rules, but it does not automatically scale like hardware rasterization.
A serious implementation needs multiple stages:
1. Transform vertices and clip triangles.
2. Compute screen bounds and assign triangles to 8x8 or 16x16 pixel tiles.
3. Store per-tile triangle lists with overflow handling.
4. Rasterize each tile in a workgroup, using workgroup memory where useful.
5. Resolve depth, alpha cutout, texture sampling, lighting, and color.
6. Run color quantization/dither and copy to the canvas.
Expected speed:
A properly tiled compute renderer could plausibly reduce the current scene to
roughly 1-5 ms on a decent desktop GPU. A naive port can be slower than the current
worker renderer. Exact performance depends on tile occupancy, atomics, overdraw,
texture access, and hardware.
Scaling limitations:
- Vertex transformation and binning still grow with triangle count.
- Dense tiles grow with local triangle count and overdraw.
- Triangle-parallel writes contend on depth and color.
- Pixel-parallel loops over every triangle are catastrophically expensive.
- Correct alpha-cutout depth ordering complicates packed atomic updates.
- Reproducing deterministic software ordering can serialize work.
Use this option when exact software-raster behavior is more important than maximum
content headroom. It is not the recommended first answer to the current scaling
problem, but it remains open.
### Option D: CPU rasterizer with GPU presentation
Keep software rendering and upload the completed CPU framebuffer into a GPU texture
for presentation.
Strengths:
- Small migration step.
- Preserves exact current pixels.
- Can establish canvas, texture-upload, scaling, and backend-selection plumbing.
- May simplify or replace Canvas2D presentation.
Limitations:
- Does not move triangle or pixel work off the CPU.
- Does not solve frame-rate collapse as content grows.
- Adds an upload every frame.
This remains useful as an intermediate bridge or diagnostic backend, not as the
destination for the stated performance goal.
### Option E: both WebGPU and WebGL2
Implement a common backend contract, then supply WebGPU, WebGL2, and software
implementations.
Strengths:
- Best runtime coverage while retaining modern WebGPU capabilities.
- Allows direct performance comparison on the same machine and scene.
- Software remains the reference renderer.
Costs:
- Three renderers, shader languages, resource systems, and failure paths.
- PS1 feature fixes must be maintained in multiple implementations.
- Highest test and debugging burden.
Do not start both GPU backends simultaneously unless supported-browser requirements
make that necessary. The common seam should permit a second backend without making
it mandatory.
## Current architecture that should survive
No game-content rewrite is needed.
- `engine/world/Level.ts` owns live actors, collision, terrain, and render projection.
- `engine/render/RenderScene.ts` owns static groups, chunks, billboards, prototypes,
sky configuration, culling, and scene-local instance data.
- `engine/render/Chunk.ts` owns LOD choice.
- `Mesh` already provides flat position/UV vertices and indexed triangles.
- `Material` already binds a concrete `Texture` and cull setting.
- `ChunkBuilder` already batches static geometry by material object.
- Actor definitions already expose material-bound render prototypes.
- Main-thread frustum culling already prevents invisible chunks and actors from
reaching the renderer.
GPU compilation should consume those objects directly. Backend-local maps may
deduplicate resources by `Mesh`, `Texture`, `Material`, or `RenderPrototype` object
identity. Such maps are compiled-resource caches, not semantic content registries:
they have no content names, stable IDs, or dispatch behavior.
## Proposed backend seam
Current `app/renderer.ts` exposes software-specific `fb` and `parallel` fields.
Presentation also lives partly in `app/main.ts`. A GPU backend needs a backend-neutral
surface.
Candidate frame input:
```ts
export type RenderFrame = {
camera: Camera
viewProjection: Mat4
visibleChunks: readonly number[]
instances: readonly RenderInstance[]
time: number
}
```
Candidate renderer contract:
```ts
export type Renderer = {
readonly width: number
readonly height: number
readonly backend: "software" | "webgl2" | "webgpu"
reconfigure: (config: RenderConfig) => void
dispatch: (frame: RenderFrame) => void
done: () => boolean
present: () => void
workMs: () => number | null
dispose: () => void
}
```
This is a candidate, not a final API. Important properties:
- Main loop no longer reads a software `Framebuffer` to discover dimensions.
- Backend owns presentation. Software calls `putImageData`; WebGL/WebGPU finish a
pass targeting the canvas.
- Software `done()` polls workers; standard GPU backends can initially report true
after command submission.
- `workMs()` may be unavailable until GPU timing support exists.
- Renderer owns teardown for workers, buffers, textures, and device/context loss.
- Scene replacement or level reload recompiles backend resources explicitly.
## Module ownership options
Core engine must remain DOM-free and usable by the server.
Recommended initial split:
- `engine/render/RenderScene.ts` keeps backend-neutral scene and frame contracts.
- `app/renderers/SoftwareRenderer.ts` wraps the current worker renderer.
- `app/renderers/WebGpuRenderer.ts` or `app/renderers/WebGlRenderer.ts` owns browser
API calls and canvas contexts.
- `app/main.ts` selects a backend and uses only the common renderer contract.
If a GPU backend grows too large for `app/`, another viable option is a separate
platform project such as `renderers/webgpu/` that depends on `engine/` but is not
part of DOM-free engine core. Do not add DOM/WebGPU globals to `tsconfig.engine.json`
merely to make placement convenient.
## Standard GPU resource compilation
Compile resources once per `RenderScene` or level load.
### Meshes
- Convert `Mesh.verts` into one `Float32Array` with stride 5: x, y, z, u, v.
- Convert `Mesh.indices` into `Uint16Array` when safe or `Uint32Array` otherwise.
- Create one backend mesh resource per distinct `Mesh` object.
- Preserve separate chunk meshes; merging all chunks would defeat CPU culling.
- Release resources when replacing the scene.
### Textures
- Upload existing packed RGBA bytes through a `Uint8Array` view.
- Keep repeat wrapping.
- Create nearest and linear samplers without mipmaps.
- Handle WebGPU `bytesPerRow` alignment with padded staging rows when needed.
- Deduplicate by `Texture` object identity.
### Materials
- Bind texture resources by `Material.texture` object identity.
- Keep culling as finite renderer capability, not content identity.
- Standard GPU paths will probably need two world pipeline variants: culled and
double-sided.
- Avoid one shader program or pipeline per game material.
### Prototypes and instances
- Compile each `RenderPrototype` once.
- Group visible instances by prototype each frame.
- Upload transform data once per frame.
- Draw repeated actors with instancing rather than one command per actor.
- Keep prototype indexes local to the compiled scene.
### Billboards
- Replace per-frame CPU quad construction with one static unit quad.
- Supply position/size as instance data.
- Derive camera-right direction in the vertex shader so billboards stay upright.
## Standard GPU frame structure
### Pass 1: scene color and depth
Render at `RenderConfig.internalWidth` x `RenderConfig.internalHeight` into an
offscreen RGBA color texture plus depth attachment.
Suggested order:
1. Draw full-screen sky with depth writes disabled.
2. Draw static groups.
3. Draw visible chunks using CPU-selected near/far groups.
4. Draw billboards with alpha cutout and no culling.
5. Draw actor prototype instances.
### Pass 2: quantization and presentation
Sample the offscreen color texture and apply color-depth quantization plus Bayer
dither in a full-screen pass. Write to the canvas texture. Keep CSS integer scaling
and `image-rendering` behavior where useful.
This two-pass shape matches current behavior better than quantizing each material
fragment independently because software quantization runs after the complete frame,
including sky.
## Mapping current raster features to shaders
| Current feature | Standard GPU implementation | Parity risk |
| --- | --- | --- |
| Perspective-correct UV | Native interpolation | Low; this is already desired behavior |
| Internal low resolution | Offscreen render target at configured size | Low |
| Nearest/linear texture filter | Select sampler from `RenderConfig` | Low |
| No mipmaps | Allocate only base level and use non-mipmap sampler | Low |
| Vertex snap | Snap projected screen coordinates in vertex shader, then rebuild clip xy | Medium; edge rules differ |
| Flat lighting | Fragment derivatives of world/view position, or baked face normals | Medium |
| Distance fog | Fragment shader using view-space distance | Low |
| Alpha cutout | Fragment `discard` below alpha threshold | Low |
| Backface culling | Culled and double-sided pipeline variants | Low; front-face sign must be checked |
| Color depth | Full-screen post-process quantization | Low |
| Bayer dither | Full-screen integer pixel-coordinate lookup | Low |
| 1/w depth behavior | Native perspective depth or explicit fragment depth | Medium; exact values differ |
| Near clipping | Native clipping | Low visually, not pixel-identical |
| Procedural sky | Full-screen sky shader port | Medium; noise parity must be tested |
| Chunk frustum culling | Keep existing CPU `RenderScene.visibleChunks` | Low |
| Actor culling | Keep existing `Level.visibleInstances` | Low |
| Chunk LOD | Keep existing CPU `Chunk.isFar` selection | Low |
Vertex snap sketch:
```text
clip = projection * view * model * position
ndc = clip.xy / clip.w
pixel = (ndc * 0.5 + 0.5) * internalResolution
pixel = round(pixel / snap) * snap
clip.xy = ((pixel / internalResolution) * 2.0 - 1.0) * clip.w
```
Y orientation and half-pixel conventions differ between APIs and need screenshot
tests. Do not guess front-face or snap signs from the software renderer.
## Flat-lighting options
### Fragment derivatives
Calculate a face normal from `cross(dpdx(position), dpdy(position))` in the fragment
shader. This preserves flat faceted lighting without changing mesh data.
Pros:
- Smallest geometry change.
- Naturally one normal per rasterized face.
- Available in WebGL2 fragment shaders and WGSL fragment shaders.
Cons:
- Exact shade may differ from the CPU cross-product calculation.
- Orientation and two-sided handling need validation.
### Baked face normals
Duplicate shared vertices per triangle and store one face normal per vertex.
Pros:
- Explicit and predictable.
- Can reproduce CPU directional-light math closely.
Cons:
- Increases vertex memory.
- Requires mesh compilation to expand indexed geometry.
- Changes cache behavior.
Both options remain open. Derivatives are the recommended first implementation.
## WebGPU-specific notes
- Request adapter/device once and handle failed acquisition cleanly.
- Configure the canvas using the preferred canvas format.
- Use an offscreen `rgba8unorm` scene texture and `depth24plus` initially.
- Keep frame uniforms in a uniform buffer.
- Use dynamic uniform offsets, instance vertex attributes, or storage buffers for
model transforms. Do not repeatedly overwrite one model uniform before submit.
- Respect 256-byte dynamic-uniform alignment.
- Group actors by prototype and use `instance_index` for transform lookup.
- Keep bind-group layouts stable across materials.
- Separate frame data, texture views, and samplers so changing texture filtering
does not force rebuilding every texture resource.
- Listen for device loss and switch to software rather than leaving a dead canvas.
- Treat timestamp queries as optional capability.
- Recreate size-dependent color/depth textures after `RenderConfig` resolution
changes.
## WebGL2-specific notes
- Request `webgl2` with alpha and antialias settings chosen explicitly.
- Use one VAO per compiled mesh.
- Use an FBO with RGBA color texture and depth renderbuffer/texture.
- Use GLSL ES 3 vertex/fragment programs for world, billboard, sky, and post passes.
- Use `drawElementsInstanced` and per-instance matrix attributes for actors.
- Use `dFdx`/`dFdy` fragment derivatives for flat lighting if selected.
- Use `EXT_disjoint_timer_query_webgl2` only when available.
- Handle context loss/restoration by releasing and recompiling scene resources.
- Reset or centralize state changes; implicit stale state is a major WebGL failure
mode.
## WebGPU compute-raster details
Avoid these naive designs:
- One compute invocation per pixel looping over every triangle.
- One invocation per triangle writing non-atomic color and depth.
- A global atomic lock per pixel around full shading.
- Unbounded fixed-size tile lists with silent overflow.
- Recreating CPU row-band splitting on the GPU.
Open design questions for compute:
- Tile size and maximum triangle references per tile.
- Multi-pass prefix sums versus fixed tile capacity.
- Depth encoding suitable for atomic comparison.
- How alpha-cutout fragments update depth and color consistently.
- Whether exact triangle order is required for equal-depth fragments.
- Whether perspective-correct UV and texture filtering use native sampling.
- How clipping-generated triangles enter tile lists.
- Whether post-process quantization remains a separate render pass.
Do not implement compute first unless pixel parity is declared a hard requirement
after testing a standard raster prototype.
## Draw-call and batching strategy
Current chunks are already grouped by material, which is suitable for GPU upload.
Culling should remain more important than globally merging geometry.
Initial strategy:
- Draw each visible chunk group separately.
- Skip invisible chunks on CPU.
- Select near/far groups on CPU.
- Sort or bucket visible groups by cull pipeline and material only if profiling
shows draw-call overhead matters.
- Instance actors by prototype.
- Use one unit-quad mesh for billboards.
Possible later optimizations:
- Merge neighboring chunk groups only if visibility remains acceptably granular.
- Multi-draw or indirect draws where API support and complexity justify them.
- GPU frustum/occlusion culling only after CPU submission becomes measured cost.
- Texture atlases or arrays only after material binding becomes measured cost.
Do not add batching abstractions before a profile identifies the bottleneck.
## Performance expectations and growth testing
Standard GPU rasterization is recommended for the content-scaling goal because
hardware raster units remove the CPU per-triangle/per-pixel loop. However, GPU
rendering can still lose frame rate through excessive draws, overdraw, huge texture
uploads, per-frame allocation, or synchronization.
Benchmark growth levels, not only the current scene:
| Scenario | Purpose |
| --- | --- |
| 1x current content | Parity and baseline |
| 2x static props | Early scaling slope |
| 5x static props | Dense-world target |
| 10x static props | Stress and culling behavior |
| 2x/5x moving actors | Instance upload and draw scaling |
| Worst forest camera | Fill, overdraw, and visible draw count |
| Empty clearing | Fixed per-frame overhead |
Record:
- CPU simulation time.
- CPU render submission time.
- GPU time when available.
- Median, p95, and max frame time.
- Visible triangles, draw count, and visible actor count.
- GPU memory and scene compilation time where practical.
Candidate budget, not yet agreed:
- Keep renderer p95 below roughly 10 ms at the chosen content target, leaving
room inside a 16.67 ms frame for simulation, input, browser, and presentation.
- Demonstrate a substantially flatter frame-time slope than software at 2x and 5x
content.
## Migration sequence
### Phase 1: isolate backend contract
1. Introduce `RenderFrame` and backend-neutral renderer dimensions/lifecycle.
2. Wrap current software renderer without changing output.
3. Move Canvas2D presentation behind the software backend.
4. Keep `?bench=st|mt` working.
5. Add explicit backend query selection such as `?renderer=software|webgl2|webgpu`.
Acceptance:
- Software screenshots and benchmark remain unchanged.
- Preset switching, resize, worker fallback, and presentation still work.
### Phase 2: GPU scene compiler
1. Traverse `RenderScene` once.
2. Deduplicate meshes, textures, materials, and prototypes by object identity.
3. Upload immutable resources.
4. Record compile time and resource counts.
5. Add disposal and level-reload behavior.
Acceptance:
- Compiling a scene does not mutate engine scene data.
- No content names or registries enter backend code.
- Repeated object references produce one GPU resource.
### Phase 3: minimum standard-raster world
1. Render static groups and visible chunk groups.
2. Add depth testing and material culling.
3. Add texture sampling.
4. Keep CPU culling and LOD.
5. Use a simple clear color before sky parity exists.
Acceptance:
- Geometry, UVs, winding, depth, and near/far LOD are visibly correct.
- Current scene already shows major software-to-GPU work-time reduction.
### Phase 4: actors and billboards
1. Compile render prototypes.
2. Group visible actors by prototype.
3. Upload per-frame transforms.
4. Draw actors with instancing.
5. Draw billboards from a shared unit quad.
6. Add alpha cutout.
Acceptance:
- No actor content dispatch exists in backend.
- Worker scene-local prototype semantics remain backend-neutral.
- Dynamic actor count scaling is measured.
### Phase 5: PS1 look parity
1. Implement vertex snap.
2. Implement nearest/linear sampler selection.
3. Implement flat lighting.
4. Implement fog.
5. Implement color-depth quantization and Bayer dither in post-processing.
6. Validate `standard`, `soft`, `clean`, and `ps1` presets.
Acceptance:
- Every current `RenderConfig` knob works live.
- No PS1 trait is silently hardcoded into GPU shaders.
### Phase 6: sky parity
1. Port gradient and sun.
2. Port skybox sampling.
3. Port basic cumulus.
4. Port fancy cumulus.
5. Preserve reduced cloud sampling where still useful.
Acceptance:
- Sky animation and panorama orientation match visually.
- Cloud cost is profiled separately at each internal resolution.
### Phase 7: reliability and selection
1. Handle WebGPU device loss or WebGL context loss.
2. Fall back to software when GPU initialization fails.
3. Verify preset switching and resize during active rendering.
4. Add screenshot comparison and growth benchmarks.
5. Decide default backend only from support and measured results.
## Validation plan
### Visual scenes
Capture stable views that expose different failure modes:
- Room edge crossing near plane.
- Terrain horizon with fog.
- Dense trees and boulders at LOD transition.
- Flowers and sprite alpha cutout.
- Frog/bee/robin close and far.
- Skybox poles and seam.
- Basic and fancy clouds.
- Backface-culling winding checks.
### Comparison policy
If standard GPU rasterization is selected, require visual equivalence rather than
bit-identical frames unless exact parity is later declared. Native clipping, depth,
edge fill, derivatives, and floating-point behavior will differ.
If compute rasterization is selected specifically for exactness, define which
parts must match bit-for-bit before implementation. "Exact" cannot remain vague.
### Automated checks
- GPU scene compiler imports no game modules.
- Renderer backends import no concrete game modules.
- GPU compilation creates no stable content IDs.
- Software tests continue passing.
- Shader compilation errors fail backend initialization clearly.
- Scene replacement releases old resources.
- Config switch and resize recreate only size/config-dependent resources.
- Device/context loss reaches a working fallback.
## Backend choice guide
Choose standard WebGPU first when:
- Target browsers/devices support WebGPU adequately.
- Future compute or storage-buffer work matters.
- Explicit resource management is worth the larger initial implementation.
- Software fallback is acceptable where WebGPU is absent.
Choose WebGL2 first when:
- Broad browser/device coverage is the priority.
- Fastest path to hardware rasterization is the priority.
- Current feature set is enough and compute is not required.
- Maintaining software fallback covers correctness/reference needs.
Choose WebGPU compute first only when:
- Exact custom raster behavior is a hard product requirement.
- Standard GPU raster tests prove shader emulation insufficient.
- The project accepts a tile-binning/atomic rasterizer as a major subsystem.
- WebGPU-only support is acceptable.
Choose both standard GPU APIs only when:
- Browser support requirements cannot be met by one GPU API plus software fallback.
- The maintenance cost is explicitly accepted.
## Current recommendation
Start with the backend seam, then prototype **standard WebGPU rasterization** while
keeping software fully operational. Standard WebGL2 remains a valid first backend
if target-browser research favors it. Measure one textured static world pass before
porting every visual feature.
Do not begin with compute unless exact edge/depth behavior is declared mandatory.
For the stated problem, standard hardware rasterization offers the strongest chance
of adding substantially more content without frame time growing like the current
CPU rasterizer.
## Questions required before implementation
1. Which browsers, OS versions, and device classes must run the GPU path?
2. Is software-renderer pixel identity required, or is visual equivalence enough?
3. What content-growth target must hold 60 fps: 2x, 5x, 10x, or a concrete level?
4. Is the software backend a permanent supported mode or only migration/reference?
5. Is GPU timing required in the HUD for the first version?
6. Should backend selection be automatic, query-driven, or user-configurable?
7. Is WebGPU compute still desired after a standard-raster visual prototype exists?
Until these are answered, keep all options above open and avoid API choices that
make WebGL2, WebGPU rasterization, or WebGPU compute unnecessarily impossible.

View file

@ -1,9 +1,18 @@
# Levels & the in-game editor — direction note
Status: **direction agreed, not yet built.** Captures a brainstorm so the next
Status: **historical proposal, partially superseded.** Captures a brainstorm so the next
session starts from the conclusion, not a cold read. Nothing here is committed
code; it's the shape we want and *why*.
> **Architecture note (2026-08-20):** Naming and ownership sections below are
> superseded by `docs/adr/0001-engine-owns-world-concepts.md` and `CONTEXT.md`.
> Canonical terms are now engine `LevelDefinition` (live-level creation input),
> engine `Level` (live runtime), and clone-safe engine `RenderScene`; game owns
> concrete definitions and values. A future serializable editor document needs its
> own name rather than overloading these runtime terms. References below to game-owned world contracts, content
> `kind` fields/registries, `game/Terrain.ts`, or `game/renderScene.ts` describe the
> old code only. Editor goals remain valid but must use the canonical boundary.
## The pivot
The game is heading toward a **multiplayer twitch shooter**. Each match loads a
@ -20,7 +29,7 @@ The game is heading toward a **multiplayer twitch shooter**. Each match loads a
- **Persistence and player carry-over are YAGNI** for now — but must not be walled
off (see hedges).
## Naming: `Level` (data) vs `Scene` (runtime)
## Historical naming proposal: `Level` (data) vs `Scene` (runtime)
Agreed vocabulary — reads as **"bake a `Level` into a `Scene`."** Fits the
type + namespace convention.
@ -210,7 +219,7 @@ field = mutate the document + re-bake.
- **No hardcoded buffer sizes** tied to today's world — mob/framebuffer sizing already
re-runs on `setup`; keep it so odd-shaped match maps just work.
## Current-state facts the next session will need
## Historical current-state facts (before 2026-08-20)
- `buildLevel(textures): Level` (`game/level.ts:175`) is the single entry, called once
in `app/main.ts`. Today's `Level` (`game/level.ts:69-81`) is a **bake result**

View file

@ -1,29 +0,0 @@
---
description: Local vs sync mode rules for git and Forgejo — commit, push, and PR policy per workflow.
---
# Forge Sync Rules
When a workflow uses explicit mode:
- `Mode: sync` or `--sync` means sync mode.
- no sync marker means local mode.
- Do not ask whether git workflow should be agent-managed.
Local mode:
- No push.
- No forge write tools.
- No PR create/update/comment.
Sync mode:
- Commit completed workflow units.
- Push after each unit commit.
- Create PR after first push if missing.
- Reuse existing PR on later pushes.
- Comment/update PR only when workflow asks for it.
Forbidden always:
- No force push.
- No branch deletion.
- No hard reset.
- No amending unless explicitly requested.

View file

@ -1,19 +0,0 @@
---
description: Rules for OpenSpec propose and archive workflows.
---
# OpenSpec Instructions
## OpenSpec Propose Workflow
When proposing changes via `/opsx-propose`:
- If the change introduces or modifies user-facing behavior (API changes, conventions), include a task section for updating `README.md` in the proposal's impact assessment.
## OpenSpec Archive Workflow
When archiving tasks via `/opsx-archive`:
1. Automatically sync specs, do not ask the user.
2. If mode is explicit, use `forge-sync` for archive commit, push, PR, and Forgejo behavior.
3. If no mode is explicit, default to local mode.

View file

@ -1 +0,0 @@
../.agents/skills

143
AGENTS.md
View file

@ -26,14 +26,19 @@ rules live in `.agents/rules/*.md`.
frame; on-screen solids also **backface-cull**. This is what keeps a dense world
(thousands of trees/rocks) affordable — off-screen content costs ~nothing.
- **2D assets only.** Sprites/billboards (PS1-style), **no 3D model loading**.
- **Three layers, one-way deps: `engine` ← `game` ← `app`.** `engine/` is the
reusable, content-agnostic **mechanism** (rasterizer, framebuffer, culling, the
`Actor`/`Material` interfaces) — no DOM, no game content, could run server-side.
`game/` is **this game's content** (the creatures/props, terrain, level, scene
assembly + render orchestration) built on the engine interfaces. `app/` is
**browser glue** (canvas, input, image decode, the worker render driver + loop).
- **Three layers, one-way deps: `engine` ← `game` ← `app`.** `engine/` owns
reusable, content-agnostic **concepts and mechanisms**: levels, terrain, actors,
prefabs, collision, render scenes, chunking/LOD, rasterization, and worker render
transport. `game/` owns **this game's concrete content**: actual level values,
placements, materials, and frog/bee/robin or oak/spruce/birch definitions.
`app/` is browser glue (canvas, input mapping, image decode, workers, frame loop).
Enforced: `engine/` imports nothing from `game`/`app`, `game/` nothing from `app`
(`tests/layering.test.ts`). Both `engine` and `game` are DOM-free (tsconfig).
- **No closed game-content kinds or registries.** Instances reference concrete
`ActorDefinition`/`Prefab` objects directly. Do not add `MobKind`, `TreeKind`,
`*_KINDS`, content dispatch switches, or stable content-order protocols. Numeric
prototype indexes are scene-local engine transport details only. Closed unions
remain valid for finite engine capabilities such as collider or cloud shape.
## Stack & tooling
@ -57,7 +62,7 @@ rules live in `.agents/rules/*.md`.
- `bunx tsc --build tsconfig.app.json` — **typecheck the engine+game+app graph. Use
this**, not `bun run check` (see Caveats).
- `bunx oxlint engine game app` — lint.
- `bun test` — tests (registry id-order + engine↛game layering guards).
- `bun test` — tests (world compilation, render protocol, boundary guards).
- `bun run serve` — Bun server (`server/server.ts`, a stub for now).
## Layout
@ -71,73 +76,51 @@ rules live in `.agents/rules/*.md`.
(optional backface cull per draw), `Frustum` (6 planes from the viewProj +
AABB test, for chunk culling), `Texture` (nearest/bilinear, wrapping, no
mipmaps), `Material` (texture + cull flag; a `DrawGroup` pairs a mesh with one,
so the renderer draws by list, not by named texture), `Sky` (gradient + sun +
procedural clouds; renders at 1/`step` res).
so the renderer draws by list, not by named texture), `Chunk`/`ChunkBuilder`
(spatial batches + two-level LOD), `RenderScene` (clone-safe scene projection,
culling + band rendering), `RenderProtocol` (shared worker frame layout), and
`Sky` (full-resolution gradient + sun, procedural clouds sampled at 1/`step`).
- `scene/``Camera` (fps yaw/pitch; far plane reaches the outdoor peaks),
`Mesh` (indexed tris; verts stored flat: `STRIDE` floats x,y,z,u,v per vertex,
no per-vertex objects — cache-friendly + alloc-free to draw), `Sprite`
(Y-axis billboard), `Actor` (the generic `Entity<State, World>` interface —
build + update + bounds — that a content kind implements; the engine dispatches
through it, never a `kind` switch).
- `game/` — this game's content + world assembly, on the engine interfaces
(Y-axis billboard), `MeshBuilder` (generic quad/slab/box construction), `Actor`
(open behavior + render/collider contract), and `Prefab` (open static-content
contract + type-erased placed value).
- `world/``Terrain` (height contract, built-in rolling generator, generic
patch meshing), `Collider`/`CollisionWorld`, `CharacterController`, and `Level`
(live actor/collision state + clone-safe `RenderScene` projection).
- `game/` — this game's definitions and level data, on the engine interfaces
(headless: no DOM, imports nothing from `app`).
- `actors/` — the placeable things. `Mob` (a **roaming** creature — `frog` hops
the ground, `bee` hovers/darts, `robin` mostly hops but now and then takes a
short powered flight — the only moving geometry; each kind an `Entity` in
`mobs/<Kind>.ts` + shared `mobs/mobkit.ts`, assembled by the thin `Mob` registry.
Its local-space mesh is built once per kind; `Mob.update` steps the wander AI
(leashed to a home anchor, deterministic per evolving `seed`) each frame and the
live `position`/`heading`/`scale` become a per-frame model matrix at draw.
`MOB_KINDS` is the SAB id order). `Tree` (oak/spruce/birch, each a `TreeSpecies`
in `trees/<Kind>.ts` + `trees/treekit.ts` — see the Trees section), `Boulder`
short powered flight — the only moving geometry; each module exports an
`ActorDefinition` factory and instances hold the resulting object directly.
`Tree` is shared placement state; Oak/Spruce/Birch modules export material-bound
`Prefab<Tree>` factories with no species registry. `Boulder`
(squashed jittered part-buried sphere), `Bush` (leaf-blob cluster, shares the
leaf mesh), `Flower` (stem + colored bloom, 2x2 atlas, double-sided). Baked props
append into shared per-material meshes; mobs draw live.
- `Terrain.ts` — procedural heightfield around the room (flat clearing, rolling
hills, tall edge peaks). `Terrain.patch` builds one ground patch over a rectangle
(per chunk, welds crack-free, hole for the room); `Terrain.height` is the shared
ground sampler for the player + mobs.
- `level.ts` — builds the playground: a flat stone-floored room (three thick
walls via `slab`, north side open) always drawn, in the center of a big grassy
`Terrain` world (~20x across). Props are placed first (`placeTrees` /
`placeBoulders` / `placeBushes` / `placeFlowers` → instance lists + colliders;
`TREE_/BOULDER_/BUSH_/FLOWER_COUNT`/`_SEED`/`_REACH`) and the roaming mobs
scattered (`placeMobs`; `FROG_/BEE_/ROBIN_COUNT`, `MOB_SEED`, `MOB_REACH` — mobs
move, so no baked colliders), then `buildChunks` bakes terrain + props into a
`CHUNK_GRID` x `CHUNK_GRID` grid of `Chunk`s (each = a tight AABB + two
`DrawGroup[]` lists `near`/`far`; the baker accumulates one mesh per **material
key** (`MAT_ORDER`) and routes each prop by its declared material, so it names no
texture) that `main` frustum-culls. Trees + boulders bake **twice** — full into
`near`, a low-poly impostor into `far` — so a far chunk swaps to the cheap set
with no per-frame work (`chunkFar` / `RenderConfig.lodDistance`).
`buildLevel(textures)` binds the ground/prop `Material`s once + shares them.
Also: `Aabb` colliders, NPC position, `TERRAIN`/`TERRAIN_SUBDIV`/`GROUND_UV`,
sky/cloud config, `FLOOR_LIFT` (a z-bias lifting the stone floor over the terrain
skirt). Room surfaces are single flat quads (texturing is perspective-correct).
- `renderScene.ts``renderBand(fb, scene, …, mobDraws, …, y0, y1)`: the single
source of render truth (sky + room + culled chunk draw-groups + sprite + roaming
mobs + quantize for a row band). Used full-height by the inline path, per-band by
each worker. `Scene` bundles the static meshes/textures (incl. the canonical mob
meshes) so it clones to a worker whole; each mob draws double-sided through its
own `viewProj × Mat4.compose(...)` model matrix, and `visibleChunks`/`visibleMobs`
frustum-cull per frame. `textures.ts` holds the `Textures` palette type.
- `player.ts` — feet-cylinder player: gravity/jump + Shift-run (`RUN_MULTIPLIER`)
+ circle-vs-AABB/-circle collision, substepped so fast running can't tunnel
walls; ground height from `Terrain.height` (plus standable AABBs).
- `level.ts` — concrete playground values and placement policy: room dimensions,
rolling-terrain parameters, counts/seeds/reach, material bindings, sky, and
spawn definition lists. It places direct `Prefab` objects, submits the resulting
values to engine `ChunkBuilder`, then creates engine `Level`; no game renderer or material-key
registry exists. Trees + boulders bake full and far-impostor geometry; bushes
and flowers provide near geometry only.
- `player.ts` — concrete player tuning only; movement and collision live in
engine `CharacterController`.
- `app/` — browser glue only (top layer; depends on `game` + `engine`).
- `main.ts` — game loop: input, sim, preset switching, per-frame culling, then
the non-blocking pump (`renderer.dispatch`/`done`) + `present` (GPU/CSS upscale)
+ a multi-line frame HUD (`work + present` critical-path ms, vsync, visible
chunks / LOD-aware tris). Owns the **mob sim**: steps `Mob.update` for every mob,
rebuilds near-player mob colliders into `level.colliders`, culls mobs
(`visibleMobs`) so only visible transforms dispatch.
chunks / LOD-aware tris). Calls engine `Level` simulation/collider extraction,
maps browser keys into `CharacterInput`, and dispatches visible render instances.
- `renderer.ts` — the render driver. When the page is cross-origin-isolated it runs
a pool of `render-worker.ts` threads (`MAX_WORKERS`) over a `SharedArrayBuffer`
framebuffer, each owning a disjoint row band, synced by a lock-free `Atomics`
barrier; otherwise inline. `dispatch`/`done` are non-blocking so the caller paces
on rAF. Per-frame inputs ride shared arrays: camera/matrix/visible-chunk list +
visible **mob transforms** (`mobState`, count in `MOBVIS`). `?bench=st|mt` A/Bs
the paths.
visible engine instance prototype indexes + transforms. `?bench=st|mt` A/Bs
the paths. Renderer and worker import no game modules.
- `assets.ts` — load `/assets/*.png``Texture` (zero-copy; ImageData bytes are
already the `Color` layout); returns the `game` `Textures` palette.
- `index.html` — Vite entry at repo root; holds the `#screen` canvas and the
@ -155,9 +138,10 @@ rules live in `.agents/rules/*.md`.
## Frame pipeline (`app/main.ts` `tick`)
`Mob.update` (all mobs) + rebuild near-player mob colliders → `Player.update`
build `Camera``Camera.viewProjection``visibleChunks` + `visibleMobs`
(frustum-cull, once on the main thread) → `renderer.dispatch` (non-blocking) →
`Level.update` (all actors) + `Level.refreshActorColliders`
`CharacterController.update` → build `Camera``Camera.viewProjection`
`RenderScene.visibleChunks` + `Level.visibleInstances` (frustum-cull, once on the
main thread) → `renderer.dispatch` (non-blocking) →
next rAF: `renderer.done()` ? `present` : skip this vsync. Frame N is presented
while N+1 is dispatched; the pump never blocks or async-awaits, so it can't
desync from rAF.
@ -171,16 +155,15 @@ an integer multiple (crisp letterbox, centered) once per resize/config, and
`linear`). This replaced a per-frame main-thread `drawImage` that scaled to the
whole window (cost grew with window size); present is now ~0.2ms.
`renderBand` runs `renderScene.renderBand` for rows [y0,y1): `Sky.render` at
1/`SKY_STEP` res (fills color + resets depth, replaces a clear) → `Rasterizer.draw`
`renderBand` runs `RenderScene.renderBand` for rows [y0,y1): `Sky.render` with
clouds sampled at 1/`SKY_STEP` res (fills color + resets depth, replaces a clear) → `Rasterizer.draw`
floor/walls/crate (room, always) → for each visible `Chunk`, loop its draw-groups —
`near` or `far` chosen by the pure `chunkFar` test (dist² from camera to the chunk
`near` or `far` chosen by the pure `Chunk.isFar` test (dist² from camera to the chunk
AABB vs `lodDistance²`): `near` is grass + full trees/rocks + flowers, `far` is grass
+ the cheap impostors (foliage/flowers dropped). Each group draws with its own
`Material` (cull per-material, so solids backface-cull and flowers stay double-sided)
`Sprite.billboard(npc)`
the roaming mobs (each: shared local mesh × its `Mat4.compose` model matrix,
double-sided) → `Framebuffer.quantize`. `chunkFar` is pure (camera + baked bounds + config
→ each billboard → each visible actor prototype instance (shared local draw groups ×
its `Mat4.compose` model matrix) → `Framebuffer.quantize`. `Chunk.isFar` is pure (camera + baked bounds + config
only), so every worker band picks the same LOD for a chunk → no horizontal seam.
Multi-threaded: N workers each run `renderBand` over
their band of the shared framebuffer in parallel; single-threaded: one call over
@ -225,9 +208,9 @@ off-screen or fogged each frame, so several things keep it cheap:
chunks that fall outside the view. Behind you + off to the sides = free.
- **Backface culling** (`draw(..., true)`) — ~halves fill on solid geometry
(terrain, foliage, rock). See the Rasterizer note re winding.
- **Half-res sky** (`SKY_STEP` in `main`, default 2) — the cloud fbm runs per
- **Half-res clouds** (`SKY_STEP` in `app/renderer.ts`, default 2) — cloud fbm runs per
pixel and dominated the frame; sampling once per 2×2 block quarters it.
- **Distance LOD** (`RenderConfig.lodDistance`, `chunkFar` in `renderScene`) —
- **Distance LOD** (`RenderConfig.lodDistance`, `Chunk.isFar`) —
past `lodDistance` a chunk's trees + boulders swap to pre-baked low-poly
impostors and its bushes/flowers drop; both meshes are baked once at load, and
the near/far pick is a pure function of camera + chunk bounds, so it costs
@ -282,11 +265,10 @@ branching in the cloud shader. Cost scales with sky resolution — fine at
## Trees (`game/actors/Tree.ts` + `game/actors/trees/`)
Procedural low-poly geometry, faceted flat-shaded like everything else. Each species
is a `TreeSpecies` definition in its own `trees/<Kind>.ts` module (geometry +
which chunk materials its trunk/foliage bake into); `Tree.ts` just assembles them
into a registry (`Tree.species(kind)`, `TREE_KINDS`) and shared primitives live in
`trees/treekit.ts`. Three `kind`s carry the species read purely by silhouette:
Procedural low-poly geometry, faceted flat-shaded like everything else. `Tree.ts`
contains only shared placement state. Oak, Spruce, and Birch each expose a concrete
`Prefab<Tree>` factory from their own module; level data references those definition
objects directly, and shared geometry primitives live in `trees/treekit.ts`.
- **`oak`** — short tapered trunk, a couple of branches, a broad cluster of
lumpy canopy `blob`s (wider than tall, bushy).
- **`spruce`** — tall thin trunk under stacked narrowing `cone` tiers pointing
@ -298,14 +280,11 @@ into a registry (`Tree.species(kind)`, `TREE_KINDS`) and shared primitives live
`growth` (0..1) runs **sapling → full grown**: it scales height/girth and adds
canopy blobs (oak/birch) / tiers (spruce); `seed` gives each tree its own wobble.
A species declares its `trunk`/`foliage` **material keys** (e.g. birch → white
`birch` trunk, oak `leaf` foliage); the chunk baker (`level.ts`) accumulates one
mesh per material key and routes each tree via `Tree.species(kind)` — so a forest
still batches into a few draw calls and the baker names no texture. `game/level.ts`
`placeTrees` seeds the forest and rolls the species. **Add a species** = add a
`trees/<Kind>.ts` module (its geometry + material keys) + one entry in the `Tree`
registry; only a genuinely new material also needs a `Material` in `buildLevel` +
its key in `MAT_ORDER`.
Each factory receives concrete trunk/foliage `Material` objects and closes over
them. `ChunkBuilder` accumulates meshes by material object, so a forest still
batches into a few draw groups without string keys or a registry. `game/level.ts`
`placeTrees` selects from a weighted list of prefab objects. **Add a species** =
add one concrete prefab module and include its object in level data.
**Boulders** (`game/actors/Boulder.ts`) work the same way: `Boulder.build`
appends a squashed, per-vertex-jittered low-poly sphere (seam/pole-safe so it
@ -317,7 +296,7 @@ colliders; add a new prop type by cloning the pattern (generator + scatter).
## Controls
WASD move · **Shift** run (speed ×`RUN_MULTIPLIER` in `game/player.ts`) · mouse
WASD move · **Shift** run (speed from `Player.config` in `game/player.ts`) · mouse
look (click canvas to pointer-lock) · **Space** jump · **1/2/3** switch look
presets. FPS shown bottom-right. The room's north wall is open — walk out onto
the terrain and toward the peaks.
@ -349,7 +328,7 @@ job tmp dir, not the repo.
## Roadmap / not yet built
In-browser RenderConfig slider panel; mipmaps; `painter` depth mode; gouraud
lighting; more cloud types; more props / a weapon; more mob kinds + smarter mob
lighting; more cloud types; more props / a weapon; more mob definitions + smarter mob
behavior (they wander + block/stand-on today, but don't yet react to the player). `shared/`
is nearly empty. The FPS meter is static HTML + `textContent` writes only — no
DOM-built UI yet (deliberate).

View file

@ -1,10 +0,0 @@
@AGENTS.md
@.agents/rules/big-red-dog.md
@.agents/rules/caveman.md
@.agents/rules/commits.md
@.agents/rules/forge-sync.md
@.agents/rules/openspec.md
@.agents/rules/quality.md
Don't nudge the user towards branching or commiting. User is perfectly capable of handling this.

29
CONTEXT.md Normal file
View file

@ -0,0 +1,29 @@
# Meat Engine
Meat separates reusable first-person game capabilities from one game's concrete world and creatures.
## Language
**Level Definition**:
A complete engine-shaped input for creating one live Level. Game code produces it from concrete values and definitions; it is not the worker rendering payload.
_Avoid_: Scene assembly
**Level**:
A live playable world created from a Level Definition, including terrain, actors, collision, and rendering state.
_Avoid_: Map, scene
**Terrain**:
A bounded ground surface that can report height at any horizontal world position.
_Avoid_: Ground mesh
**Actor Definition**:
A concrete reusable definition of one moving thing's behavior, representation, transform, and physical presence. Actor instances reference definitions directly.
_Avoid_: Mob kind, actor kind, type registry
**Prefab**:
A concrete reusable definition for static level content, including its geometry and optional physical presence. Placed prefabs reference definitions directly.
_Avoid_: Prop kind, tree kind, species registry
**Render Scene**:
The behavior-free rendering projection of a Level.
_Avoid_: Level

View file

@ -1,20 +1,18 @@
import { RenderConfig } from "../engine/render/RenderConfig"
import { RenderScene, type RenderInstance } from "../engine/render/RenderScene"
import { Camera } from "../engine/scene/Camera"
import type { Mesh } from "../engine/scene/Mesh"
import { Mob, MOB_KINDS, type MobKind } from "../game/actors/Mob"
import type { Vec3 } from "../engine/math/Vec3"
import {
CharacterController,
type CharacterInput,
} from "../engine/world/CharacterController"
import { Level } from "../engine/world/Level"
import { loadTextures } from "./assets"
import { buildLevel, type Level } from "../game/level"
import { EYE_HEIGHT, Player } from "../game/player"
import { buildLevel } from "../game/level"
import { Player, type Player as PlayerState } from "../game/player"
import { createRenderer } from "./renderer"
import { chunkFar, visibleChunks, visibleMobs, type Scene } from "../game/renderScene"
const FOV_DEGREES = 75
const FOV = (FOV_DEGREES * Math.PI) / 180
/** How close (world units) a mob must be to the player to get a live collider.
* Mobs farther than this can't be touched this frame, so skip them -- keeps the
* per-frame collider list (and the player's collision loop) short. */
const MOB_COLLIDE_RANGE = 3
const screen = document.querySelector<HTMLCanvasElement>("#screen")!
const ctx = screen.getContext("2d")!
@ -28,7 +26,11 @@ function benchCamera(tv: number): Camera {
const drift = tv * 0.12
const radius = 65 + 30 * Math.sin(tv * 0.25)
return {
position: { x: Math.cos(drift) * radius, y: 3, z: Math.sin(drift) * radius },
position: {
x: Math.cos(drift) * radius,
y: 3,
z: Math.sin(drift) * radius,
},
yaw: tv * 0.7,
pitch: 0.05 * Math.sin(tv * 0.5),
fov: FOV,
@ -39,41 +41,34 @@ function round2(n: number): number {
return Math.round(n * 100) / 100
}
function benchStats(a: number[]): { median: number; p95: number; max: number; mean: number } {
function benchStats(a: number[]): {
median: number
p95: number
max: number
mean: number
} {
const s = a.toSorted((x, y) => x - y)
const q = (p: number): number => s[Math.min(s.length - 1, Math.floor(p * s.length))]
return { median: round2(q(0.5)), p95: round2(q(0.95)), max: round2(s[s.length - 1]), mean: round2(a.reduce((x, y) => x + y, 0) / a.length) }
const q = (p: number): number =>
s[Math.min(s.length - 1, Math.floor(p * s.length))]
return {
median: round2(q(0.5)),
p95: round2(q(0.95)),
max: round2(s[s.length - 1]),
mean: round2(a.reduce((x, y) => x + y, 0) / a.length),
}
}
async function main(): Promise<void> {
const textures = await loadTextures()
const level = buildLevel(textures)
// Build each kind's canonical mesh once, shared by every instance (the sim
// supplies each mob's per-frame transform). Registry-driven -- a new kind needs
// no change here.
const mobMesh = {} as Record<MobKind, Mesh>
for (const kind of MOB_KINDS) {
const m: Mesh = { verts: [], indices: [] }
Mob.build(kind, m)
mobMesh[kind] = m
}
const scene: Scene = {
chunks: level.chunks,
floor: level.floor,
walls: level.walls,
crate: level.crate,
npc: { position: level.npcPosition, size: { x: 1.1, y: 1.5 } },
mobMesh,
mobCount: level.mobs.length,
sky: level.sky,
textures,
}
// `?bench=st` / `?bench=mt` runs a scripted flythrough and reports timings.
const benchMode = new URLSearchParams(globalThis.location.search).get("bench")
const forceWorkers = benchMode === "mt" ? true : benchMode === "st" ? false : undefined
const forceWorkers =
benchMode === "mt" ? true : benchMode === "st" ? false : undefined
let config: RenderConfig = RenderConfig.standard
const renderer = createRenderer(scene, config, forceWorkers)
const renderer = createRenderer(level.render, config, forceWorkers)
let inFlight = false
let image = new ImageData(renderer.fb.width, renderer.fb.height)
let colorBytes = new Uint8ClampedArray(renderer.fb.color.buffer)
@ -93,20 +88,30 @@ async function main(): Promise<void> {
// letterboxed upscale, done by the GPU). Recomputed only on resize/config.
function layout(): void {
const fb = renderer.fb
const scale = Math.max(1, Math.floor(Math.min(globalThis.innerWidth / fb.width, globalThis.innerHeight / fb.height)))
const scale = Math.max(
1,
Math.floor(
Math.min(
globalThis.innerWidth / fb.width,
globalThis.innerHeight / fb.height,
),
),
)
const w = fb.width * scale
const h = fb.height * scale
screen.style.width = `${w}px`
screen.style.height = `${h}px`
screen.style.left = `${(globalThis.innerWidth - w) >> 1}px`
screen.style.top = `${(globalThis.innerHeight - h) >> 1}px`
screen.style.imageRendering = config.upscaleFilter === "linear" ? "auto" : "pixelated"
screen.style.imageRendering =
config.upscaleFilter === "linear" ? "auto" : "pixelated"
}
retarget()
function useConfig(next: RenderConfig): void {
config = next
renderer.reconfigure(next)
inFlight = false
retarget()
}
@ -122,9 +127,7 @@ async function main(): Promise<void> {
return
}
const player: Player = { position: { x: 0, y: 0, z: 8 }, yaw: 0, pitch: 0, velocityY: 0, onGround: true }
// Colliders past this index are the dynamic mob ones, rebuilt every frame.
const staticColliderCount = level.colliders.length
const player: PlayerState = Player.create()
const keys = new Set<string>()
globalThis.addEventListener("keydown", (e) => {
keys.add(e.code)
@ -154,26 +157,30 @@ async function main(): Promise<void> {
return
}
player.yaw += e.movementX * 0.0025
player.pitch = Math.max(-1.4, Math.min(1.4, player.pitch - e.movementY * 0.0025))
player.pitch = Math.max(
-1.4,
Math.min(1.4, player.pitch - e.movementY * 0.0025),
)
})
// Triangles drawn this frame (room + each visible chunk, LOD-aware) for the HUD.
function frameTris(visible: number[], cam: Camera): number {
let t = level.floor.indices.length + level.walls.indices.length + level.crate.indices.length
for (const i of visible) {
const c = level.chunks[i]
const groups = chunkFar(c, cam.position, config.lodDistance) ? c.far : c.near
for (const g of groups) {
t += g.mesh.indices.length
}
}
return (t / 3) | 0
function frameTris(
visible: number[],
instances: RenderInstance[],
camera: Camera,
): number {
return RenderScene.triangleCount(
level.render,
visible,
instances,
camera.position,
config.lodDistance,
)
}
// Poll-based pump: present the finished frame, dispatch the next; if workers
// aren't done we skip this vsync (no async/rAF desync). The HUD reports the
// critical-path budget (work + present) so the real bottleneck is visible.
let inFlight = false
let last = performance.now()
let fpsLast = last
let fpsFrames = 0
@ -182,7 +189,13 @@ async function main(): Promise<void> {
let vsyncMax = 0
let lastPresent = performance.now()
let lastVisible: number[] = []
let lastCamera: Camera = { position: { x: 0, y: 0, z: 0 }, yaw: 0, pitch: 0, fov: FOV }
let lastInstances: RenderInstance[] = []
let lastCamera: Camera = {
position: { x: 0, y: 0, z: 0 },
yaw: 0,
pitch: 0,
fov: FOV,
}
function show(): void {
const p0 = performance.now()
@ -213,30 +226,42 @@ async function main(): Promise<void> {
fpsEl.textContent =
`${fps} fps${tag}\n` +
`work ${round2(workMax)} + present ${round2(presentMax)} = ${round2(workMax + presentMax)}ms\n` +
`vsync ${round2(vsyncMax)}ms · ${lastVisible.length} ch · ${frameTris(lastVisible, lastCamera)} tris`
`vsync ${round2(vsyncMax)}ms · ${lastVisible.length} ch · ${frameTris(lastVisible, lastInstances, lastCamera)} tris`
fpsLast = now
fpsFrames = 0
workMax = 0
presentMax = 0
vsyncMax = 0
}
for (const m of level.mobs) {
Mob.update(m, dt, level.terrain)
}
rebuildMobColliders(level, player.position, staticColliderCount)
Player.update(player, keys, dt, level)
Level.update(level, dt)
Level.refreshActorColliders(level, player.position, Player.actorCollisionRange)
CharacterController.update(
player,
readPlayerInput(keys),
dt,
level.collision,
Player.config,
)
const camera: Camera = {
position: { x: player.position.x, y: player.position.y + EYE_HEIGHT, z: player.position.z },
position: {
x: player.position.x,
y: player.position.y + Player.config.eyeHeight,
z: player.position.z,
},
yaw: player.yaw,
pitch: player.pitch,
fov: FOV,
}
const viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height)
const visible = visibleChunks(level.chunks, viewProj)
const mobDraws = visibleMobs(level.mobs, viewProj)
const viewProj = Camera.viewProjection(
camera,
renderer.fb.width / renderer.fb.height,
)
const visible = RenderScene.visibleChunks(level.render, viewProj)
const instances = Level.visibleInstances(level, viewProj)
lastVisible = visible
lastInstances = instances
lastCamera = camera
renderer.dispatch(camera, viewProj, visible, now / 1000, mobDraws)
renderer.dispatch(camera, viewProj, visible, now / 1000, instances)
inFlight = true
if (renderer.done()) {
show()
@ -250,7 +275,7 @@ async function main(): Promise<void> {
* interval, then reports the distributions (exposed on `window.__BENCH__`). */
function runBench(
renderer: ReturnType<typeof createRenderer>,
level: Level,
level: ReturnType<typeof buildLevel>,
present: () => void,
mode: string,
): void {
@ -282,7 +307,9 @@ function runBench(
mode,
parallel: renderer.parallel,
cores: (globalThis.navigator as Navigator).hardwareConcurrency,
coi: (globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated === true,
coi:
(globalThis as { crossOriginIsolated?: boolean })
.crossOriginIsolated === true,
res: `${renderer.fb.width}x${renderer.fb.height}`,
workMs: benchStats(work),
frameMs: benchStats(frame),
@ -306,14 +333,15 @@ function runBench(
return
}
}
for (const m of level.mobs) {
Mob.update(m, 1 / 60, level.terrain)
}
Level.update(level, 1 / 60)
const camera = benchCamera(i / 60)
const viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height)
const visible = visibleChunks(level.chunks, viewProj)
const mobDraws = visibleMobs(level.mobs, viewProj)
renderer.dispatch(camera, viewProj, visible, i / 60, mobDraws)
const viewProj = Camera.viewProjection(
camera,
renderer.fb.width / renderer.fb.height,
)
const visible = RenderScene.visibleChunks(level.render, viewProj)
const instances = Level.visibleInstances(level, viewProj)
renderer.dispatch(camera, viewProj, visible, i / 60, instances)
inFlight = true
if (renderer.done() && record()) {
report()
@ -322,29 +350,12 @@ function runBench(
requestAnimationFrame(tick)
}
/** Rebuild the dynamic tail of `level.colliders`: keep the static prefix, then add
* a block/stand-on AABB for each mob near the player. Mobs move, so these can't be
* baked. Only mobs on the ground are `standable` (hop onto a resting frog/perched
* robin); bees and airborne birds still block but never make a mid-air platform.
* Only mobs within `MOB_COLLIDE_RANGE` are added -- the rest can't be reached this
* frame anyway. */
function rebuildMobColliders(level: Level, playerPos: Vec3, staticCount: number): void {
level.colliders.length = staticCount
for (const m of level.mobs) {
const dx = m.position.x - playerPos.x
const dz = m.position.z - playerPos.z
if (dx * dx + dz * dz > MOB_COLLIDE_RANGE * MOB_COLLIDE_RANGE) {
continue
}
const half = Mob.boundingRadius(m.kind) * m.scale * 0.7
level.colliders.push({
minX: m.position.x - half,
maxX: m.position.x + half,
minZ: m.position.z - half,
maxZ: m.position.z + half,
top: m.position.y + Mob.bodyHeight(m.kind) * m.scale,
standable: m.kind !== "bee" && m.grounded,
})
function readPlayerInput(keys: Set<string>): CharacterInput {
return {
forward: (keys.has("KeyW") ? 1 : 0) - (keys.has("KeyS") ? 1 : 0),
right: (keys.has("KeyD") ? 1 : 0) - (keys.has("KeyA") ? 1 : 0),
jump: keys.has("Space"),
run: keys.has("ShiftLeft") || keys.has("ShiftRight"),
}
}

View file

@ -1,35 +1,12 @@
import type { Framebuffer } from "../engine/render/Framebuffer"
import type { RenderConfig } from "../engine/render/RenderConfig"
import { MOB_KINDS } from "../game/actors/Mob"
import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "../game/renderScene"
/** One-time setup: shared framebuffer + control/param buffers, the (cloned)
* scene, this worker's row band, and its index into the per-worker times array. */
type Init = {
colorSAB: SharedArrayBuffer
depthSAB: SharedArrayBuffer
width: number
height: number
scene: Scene
band: [number, number]
config: RenderConfig
skyStep: number
ctrlSAB: SharedArrayBuffer
camSAB: SharedArrayBuffer
vpSAB: SharedArrayBuffer
visSAB: SharedArrayBuffer
mobSAB: SharedArrayBuffer
timesSAB: SharedArrayBuffer
index: number
}
const FRAME = 0
const DONE = 1
const VIS = 2
const MOBVIS = 3
import { RenderProtocol, type RenderWorkerInit } from "../engine/render/RenderProtocol"
import { RenderScene, type RenderInstance } from "../engine/render/RenderScene"
const ctx = globalThis as unknown as {
addEventListener: (type: "message", handler: (e: { data: Init }) => void) => void
addEventListener: (
type: "message",
handler: (e: { data: RenderWorkerInit }) => void,
) => void
}
ctx.addEventListener("message", (e) => {
@ -41,31 +18,46 @@ ctx.addEventListener("message", (e) => {
depth: new Float32Array(m.depthSAB),
}
const ctrl = new Int32Array(m.ctrlSAB)
const cam = new Float64Array(m.camSAB)
const vp = new Float32Array(m.vpSAB)
const vis = new Int32Array(m.visSAB)
const mob = new Float32Array(m.mobSAB)
const cameraData = new Float64Array(m.cameraSAB)
const viewProjection = new Float32Array(m.viewProjectionSAB)
const visibleChunks = new Int32Array(m.visibleChunkSAB)
const instanceIds = new Int32Array(m.instanceIdSAB)
const instanceTransforms = new Float32Array(m.instanceTransformSAB)
const times = new Float64Array(m.timesSAB)
const { scene, band, config, skyStep, index } = m
const { scene, band, config, skyStep, workerIndex } = m
const instances: RenderInstance[] = []
// Lock-free frame loop: block until main bumps the frame counter, render this
// band, record the band time, and signal done. No messages per frame.
let last = 0
for (;;) {
Atomics.wait(ctrl, FRAME, last)
last = Atomics.load(ctrl, FRAME)
Atomics.wait(ctrl, RenderProtocol.FRAME, last)
last = Atomics.load(ctrl, RenderProtocol.FRAME)
const t0 = performance.now()
const camera = { position: { x: cam[0], y: cam[1], z: cam[2] }, yaw: cam[3], pitch: cam[4], fov: cam[5] }
const count = Atomics.load(ctrl, VIS)
const visible = [...vis.subarray(0, count)]
const mobCount = Atomics.load(ctrl, MOBVIS)
const mobDraws: MobDraw[] = []
for (let i = 0; i < mobCount; i++) {
const o = i * MOB_FLOATS
mobDraws.push({ kind: MOB_KINDS[mob[o]] ?? "frog", x: mob[o + 1], y: mob[o + 2], z: mob[o + 3], heading: mob[o + 4], scale: mob[o + 5] })
}
renderBand(fb, scene, camera, vp, visible, mobDraws, config, skyStep, cam[6], band[0], band[1])
times[index] = performance.now() - t0
Atomics.add(ctrl, DONE, 1)
const frameCamera = RenderProtocol.readCamera(cameraData)
const count = Atomics.load(ctrl, RenderProtocol.VISIBLE_CHUNKS)
const visible = [...visibleChunks.subarray(0, count)]
const instanceCount = Atomics.load(ctrl, RenderProtocol.VISIBLE_INSTANCES)
RenderProtocol.readInstances(
instanceIds,
instanceTransforms,
instanceCount,
instances,
)
RenderScene.renderBand(
fb,
scene,
frameCamera.camera,
viewProjection,
visible,
instances,
config,
skyStep,
frameCamera.time,
band[0],
band[1],
)
times[workerIndex] = performance.now() - t0
Atomics.add(ctrl, RenderProtocol.DONE, 1)
}
})

View file

@ -2,8 +2,8 @@ import { Framebuffer } from "../engine/render/Framebuffer"
import type { RenderConfig } from "../engine/render/RenderConfig"
import type { Mat4 } from "../engine/math/Mat4"
import type { Camera } from "../engine/scene/Camera"
import { MOB_KINDS } from "../game/actors/Mob"
import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "../game/renderScene"
import { RenderProtocol, type RenderWorkerInit } from "../engine/render/RenderProtocol"
import { RenderScene, type RenderInstance } from "../engine/render/RenderScene"
/** Clouds are drawn at 1/SKY_STEP resolution (the sky base + sun stay per-pixel);
* band splits align to it so the cloud block grid stays seamless across workers. */
@ -19,12 +19,6 @@ const ENABLE_WORKERS = true
* bench. */
const MAX_WORKERS = 3
// Indices into the shared control Int32Array.
const FRAME = 0 // bumped by main to dispatch a frame
const DONE = 1 // workers add 1 when their band is finished
const VIS = 2 // number of visible chunk indices this frame
const MOBVIS = 3 // number of visible mobs this frame
/**
* Render driver. When the page is cross-origin-isolated it runs a pool of worker
* threads, each owning a disjoint row band of a `SharedArrayBuffer` framebuffer;
@ -41,18 +35,37 @@ export type Renderer = {
readonly parallel: boolean
reconfigure: (config: RenderConfig) => void
/** Start rendering one frame (non-blocking in the worker path). */
dispatch: (camera: Camera, viewProj: Mat4, visible: number[], time: number, mobDraws: MobDraw[]) => void
dispatch: (
camera: Camera,
viewProj: Mat4,
visible: number[],
time: number,
instances: RenderInstance[],
) => void
/** Has the dispatched frame finished? (always true single-threaded.) */
done: () => boolean
/** Critical-path render time of the last frame, ms (max band time / inline time). */
workMs: () => number
}
export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers?: boolean): Renderer {
const hw = (globalThis.navigator as Navigator | undefined)?.hardwareConcurrency ?? 4
type PendingFrame = {
camera: Camera
viewProjection: Mat4
visibleChunks: number[]
instances: RenderInstance[]
time: number
}
export function createRenderer(
scene: RenderScene,
initial: RenderConfig,
forceWorkers?: boolean,
): Renderer {
const hw =
(globalThis.navigator as Navigator | undefined)?.hardwareConcurrency ?? 4
const workerCount = Math.max(1, Math.min(MAX_WORKERS, hw - 1))
const maxVis = Math.max(1, scene.chunks.length)
const maxMobs = Math.max(1, scene.mobCount)
const maxInstances = Math.max(1, scene.maxInstances)
let config = initial
const want = forceWorkers ?? ENABLE_WORKERS
let parallel = want && canShare()
@ -63,58 +76,116 @@ export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers
let cam: Float64Array<ArrayBufferLike> = new Float64Array(0) // pos x/y/z, yaw, pitch, fov, time
let vp: Float32Array<ArrayBufferLike> = new Float32Array(0) // the view-projection matrix
let vis: Int32Array<ArrayBufferLike> = new Int32Array(0) // visible chunk indices
let mob: Float32Array<ArrayBufferLike> = new Float32Array(0) // visible mob transforms (MOB_FLOATS each)
let instanceIds: Int32Array<ArrayBufferLike> = new Int32Array(0)
let instanceTransforms: Float32Array<ArrayBufferLike> = new Float32Array(0)
let times: Float64Array<ArrayBufferLike> = new Float64Array(0) // per-worker band render ms
let lastWork = 0
let generation = 0
let pending: PendingFrame | null = null
function setup(): void {
for (const w of workers) {
w.terminate()
function stopWorkers(): void {
for (const worker of workers) {
worker.terminate()
}
workers = []
}
function renderInline(frame: PendingFrame): void {
const t0 = performance.now()
RenderScene.renderBand(
fb,
scene,
frame.camera,
frame.viewProjection,
frame.visibleChunks,
frame.instances,
config,
SKY_STEP,
frame.time,
0,
fb.height,
)
lastWork = performance.now() - t0
}
function disableParallel(currentGeneration: number): void {
if (!parallel || currentGeneration !== generation) {
return
}
const frame = pending
parallel = false
stopWorkers()
if (frame !== null) {
renderInline(frame)
pending = null
}
}
function setup(): void {
generation++
const currentGeneration = generation
pending = null
stopWorkers()
const width = config.internalWidth
const height = config.internalHeight
if (parallel) {
const n = width * height
fb = { width, height, color: new Uint32Array(new SharedArrayBuffer(n * 4)), depth: new Float32Array(new SharedArrayBuffer(n * 4)) }
fb = {
width,
height,
color: new Uint32Array(new SharedArrayBuffer(n * 4)),
depth: new Float32Array(new SharedArrayBuffer(n * 4)),
}
const bands = splitBands(height, workerCount, SKY_STEP)
ctrl = new Int32Array(new SharedArrayBuffer(4 * 4))
cam = new Float64Array(new SharedArrayBuffer(7 * 8))
vp = new Float32Array(new SharedArrayBuffer(16 * 4))
ctrl = new Int32Array(
new SharedArrayBuffer(RenderProtocol.CONTROL_LENGTH * 4),
)
cam = new Float64Array(
new SharedArrayBuffer(RenderProtocol.CAMERA_LENGTH * 8),
)
vp = new Float32Array(
new SharedArrayBuffer(RenderProtocol.VIEW_PROJECTION_LENGTH * 4),
)
vis = new Int32Array(new SharedArrayBuffer(maxVis * 4))
mob = new Float32Array(new SharedArrayBuffer(maxMobs * MOB_FLOATS * 4))
instanceIds = new Int32Array(new SharedArrayBuffer(maxInstances * 4))
instanceTransforms = new Float32Array(
new SharedArrayBuffer(
maxInstances * RenderProtocol.TRANSFORM_FLOATS * 4,
),
)
times = new Float64Array(new SharedArrayBuffer(bands.length * 8))
try {
bands.forEach((band, index) => {
const worker = new Worker(new URL("./render-worker.ts", import.meta.url), { type: "module" })
worker.addEventListener("error", () => {
parallel = false
})
worker.postMessage({
colorSAB: fb.color.buffer,
depthSAB: fb.depth.buffer,
const worker = new Worker(
new URL("./render-worker.ts", import.meta.url),
{ type: "module" },
)
worker.addEventListener("error", () => disableParallel(currentGeneration))
const init: RenderWorkerInit = {
colorSAB: shared(fb.color.buffer),
depthSAB: shared(fb.depth.buffer),
width,
height,
scene,
band,
config,
skyStep: SKY_STEP,
ctrlSAB: ctrl.buffer,
camSAB: cam.buffer,
vpSAB: vp.buffer,
visSAB: vis.buffer,
mobSAB: mob.buffer,
timesSAB: times.buffer,
index,
})
ctrlSAB: shared(ctrl.buffer),
cameraSAB: shared(cam.buffer),
viewProjectionSAB: shared(vp.buffer),
visibleChunkSAB: shared(vis.buffer),
instanceIdSAB: shared(instanceIds.buffer),
instanceTransformSAB: shared(instanceTransforms.buffer),
timesSAB: shared(times.buffer),
workerIndex: index,
}
worker.postMessage(init)
workers.push(worker)
})
Atomics.store(ctrl, RenderProtocol.DONE, workers.length)
} catch {
parallel = false
for (const w of workers) {
w.terminate()
}
workers = []
stopWorkers()
}
}
if (!parallel || workers.length === 0) {
@ -134,44 +205,44 @@ export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers
config = next
setup()
},
dispatch(camera, viewProj, visible, time, mobDraws) {
dispatch(camera, viewProj, visible, time, instances) {
pending = {
camera,
viewProjection: viewProj,
visibleChunks: visible,
instances,
time,
}
if (parallel && workers.length > 0) {
cam[0] = camera.position.x
cam[1] = camera.position.y
cam[2] = camera.position.z
cam[3] = camera.yaw
cam[4] = camera.pitch
cam[5] = camera.fov
cam[6] = time
vp.set(viewProj)
RenderProtocol.writeCamera(cam, camera, time)
RenderProtocol.writeViewProjection(vp, viewProj)
const count = Math.min(visible.length, vis.length)
for (let i = 0; i < count; i++) {
vis[i] = visible[i]
}
const mobCount = Math.min(mobDraws.length, maxMobs)
for (let i = 0; i < mobCount; i++) {
const d = mobDraws[i]
const o = i * MOB_FLOATS
mob[o] = MOB_KINDS.indexOf(d.kind)
mob[o + 1] = d.x
mob[o + 2] = d.y
mob[o + 3] = d.z
mob[o + 4] = d.heading
mob[o + 5] = d.scale
}
Atomics.store(ctrl, VIS, count)
Atomics.store(ctrl, MOBVIS, mobCount)
Atomics.store(ctrl, DONE, 0)
Atomics.add(ctrl, FRAME, 1)
Atomics.notify(ctrl, FRAME, workers.length)
const instanceCount = RenderProtocol.writeInstances(
instanceIds,
instanceTransforms,
instances,
)
Atomics.store(ctrl, RenderProtocol.VISIBLE_CHUNKS, count)
Atomics.store(ctrl, RenderProtocol.VISIBLE_INSTANCES, instanceCount)
Atomics.store(ctrl, RenderProtocol.DONE, 0)
Atomics.add(ctrl, RenderProtocol.FRAME, 1)
Atomics.notify(ctrl, RenderProtocol.FRAME, workers.length)
return
}
const t0 = performance.now()
renderBand(fb, scene, camera, viewProj, visible, mobDraws, config, SKY_STEP, time, 0, fb.height)
lastWork = performance.now() - t0
renderInline(pending)
pending = null
},
done() {
return !(parallel && workers.length > 0) || Atomics.load(ctrl, DONE) >= workers.length
const complete =
!(parallel && workers.length > 0) ||
Atomics.load(ctrl, RenderProtocol.DONE) >= workers.length
if (complete) {
pending = null
}
return complete
},
workMs() {
if (parallel && workers.length > 0) {
@ -194,20 +265,33 @@ function canShare(): boolean {
return (
typeof SharedArrayBuffer !== "undefined" &&
typeof Worker !== "undefined" &&
(globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated === true
(globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated ===
true
)
}
function shared(buffer: ArrayBufferLike): SharedArrayBuffer {
if (!(buffer instanceof SharedArrayBuffer)) {
throw new Error("render worker buffer is not shared")
}
return buffer
}
/** Split `height` rows into ~`count` bands. Interior boundaries snap up to a
* multiple of `step` so the sky's block grid stays aligned (no seam), while the
* bands stay disjoint so no two workers write the same pixel. */
function splitBands(height: number, count: number, step: number): [number, number][] {
function splitBands(
height: number,
count: number,
step: number,
): [number, number][] {
const bands: [number, number][] = []
const per = Math.ceil(height / count)
let y = 0
while (y < height) {
const raw = y + per
const y1 = raw >= height ? height : Math.min(height, Math.ceil(raw / step) * step)
const y1 =
raw >= height ? height : Math.min(height, Math.ceil(raw / step) * step)
bands.push([y, y1])
y = y1
}

View file

@ -0,0 +1,3 @@
# Engine owns world concepts
Engine owns contracts and reusable mechanisms for levels, terrain, actors, prefabs, collision, scene rendering, chunking, LOD, and render transport; game owns concrete level values and concrete actor or prefab definitions. Game content is referenced through definition objects, never closed `MobKind`, `TreeKind`, or equivalent content registries; finite engine capability unions remain allowed. A live `Level` may contain behavior, while its `RenderScene` projection contains only clone-safe data for workers, with numeric prototype indexes confined to engine transport.

View file

@ -22,7 +22,7 @@ export namespace Mat4 {
/** Model transform T * Ry * S: uniform `scale`, then a yaw rotation about Y,
* then a translation. Built directly in column-major storage (no intermediate
* matmuls) since it runs per mob per frame. A vertex at local +Z ends up
* matmuls) since it runs per instance per frame. A vertex at local +Z ends up
* pointing along world (sin yaw, 0, cos yaw), i.e. the object faces `yaw`. */
export function compose(tx: number, ty: number, tz: number, yaw: number, scale: number): Mat4 {
const c = Math.cos(yaw)

29
engine/render/Chunk.ts Normal file
View file

@ -0,0 +1,29 @@
import type { DrawGroup } from "./Material"
import type { Vec3 } from "../math/Vec3"
export type Bounds3 = {
minX: number
minY: number
minZ: number
maxX: number
maxY: number
maxZ: number
}
/** One cullable section of static world geometry with two engine-supported LODs. */
export type Chunk = Bounds3 & {
readonly near: readonly DrawGroup[]
readonly far: readonly DrawGroup[]
}
export namespace Chunk {
export function isFar(chunk: Chunk, eye: Vec3, lodDistance: number): boolean {
if (!(lodDistance < Infinity)) {
return false
}
const dx = eye.x - Math.max(chunk.minX, Math.min(chunk.maxX, eye.x))
const dy = eye.y - Math.max(chunk.minY, Math.min(chunk.maxY, eye.y))
const dz = eye.z - Math.max(chunk.minZ, Math.min(chunk.maxZ, eye.z))
return dx * dx + dy * dy + dz * dz > lodDistance * lodDistance
}
}

View file

@ -0,0 +1,123 @@
import { STRIDE, type Mesh } from "../scene/Mesh"
import type { Chunk, Bounds3 } from "./Chunk"
import type { DrawGroup, Material } from "./Material"
export type MeshBatch = {
mesh: (material: Material) => Mesh
use: (material: Material, mesh: Mesh) => void
}
export type ChunkItem = {
position: { x: number; z: number }
bakeNear: (batch: MeshBatch) => void
bakeFar?: (batch: MeshBatch) => void
}
export type ChunkCell = {
x0: number
z0: number
x1: number
z1: number
}
export type ChunkBuilder = {
minX: number
minZ: number
maxX: number
maxZ: number
columns: number
rows: number
bakeCell: (near: MeshBatch, far: MeshBatch, cell: ChunkCell) => void
}
export namespace ChunkBuilder {
export function build(config: ChunkBuilder, items: ChunkItem[]): Chunk[] {
const width = (config.maxX - config.minX) / config.columns
const depth = (config.maxZ - config.minZ) / config.rows
const chunks: Chunk[] = []
for (let column = 0; column < config.columns; column++) {
const x0 = config.minX + column * width
const x1 = x0 + width
for (let row = 0; row < config.rows; row++) {
const z0 = config.minZ + row * depth
const z1 = z0 + depth
const nearMeshes = new Map<Material, Mesh>()
const farMeshes = new Map<Material, Mesh>()
const near = batch(nearMeshes)
const far = batch(farMeshes)
config.bakeCell(near, far, { x0, z0, x1, z1 })
for (const item of items) {
if (inCell(item.position, x0, z0, x1, z1)) {
item.bakeNear(near)
item.bakeFar?.(far)
}
}
const nearGroups = groups(nearMeshes)
const farGroups = groups(farMeshes)
const box = bounds([...nearMeshes.values(), ...farMeshes.values()])
if (box !== null) {
chunks.push({ ...box, near: nearGroups, far: farGroups })
}
}
}
return chunks
}
function batch(meshes: Map<Material, Mesh>): MeshBatch {
return {
mesh(material) {
let mesh = meshes.get(material)
if (mesh === undefined) {
mesh = { verts: [], indices: [] }
meshes.set(material, mesh)
}
return mesh
},
use(material, mesh) {
meshes.set(material, mesh)
},
}
}
function groups(meshes: Map<Material, Mesh>): DrawGroup[] {
const result: DrawGroup[] = []
for (const [material, mesh] of meshes) {
if (mesh.indices.length > 0) {
result.push({ mesh, material })
}
}
return result
}
function inCell(
position: { x: number; z: number },
x0: number,
z0: number,
x1: number,
z1: number,
): boolean {
return (
position.x >= x0 && position.x < x1 && position.z >= z0 && position.z < z1
)
}
function bounds(meshes: Mesh[]): Bounds3 | null {
let minX = Infinity
let minY = Infinity
let minZ = Infinity
let maxX = -Infinity
let maxY = -Infinity
let maxZ = -Infinity
for (const mesh of meshes) {
for (let i = 0; i < mesh.verts.length; i += STRIDE) {
minX = Math.min(minX, mesh.verts[i])
minY = Math.min(minY, mesh.verts[i + 1])
minZ = Math.min(minZ, mesh.verts[i + 2])
maxX = Math.max(maxX, mesh.verts[i])
maxY = Math.max(maxY, mesh.verts[i + 1])
maxZ = Math.max(maxZ, mesh.verts[i + 2])
}
}
return maxX < minX ? null : { minX, minY, minZ, maxX, maxY, maxZ }
}
}

View file

@ -115,8 +115,8 @@ export namespace Rasterizer {
* NEAR_W) with a single Sutherland-Hodgman pass, writing the result (0, 3, or
* 4 verts) to `dst` and returning its vertex count.
*
* This matters even when standing inside the room: a wall to your side has
* vertices both in front of and behind the eye. Without clipping, the behind
* Geometry intersecting the camera plane has vertices both in front of and
* behind the eye. Without clipping, the behind
* vertices have w <= 0 and invert under the perspective divide, smearing the
* triangle across the whole screen (and risking divide-by-zero).
*/
@ -201,7 +201,7 @@ export namespace Rasterizer {
return
}
// Backface cull: a back-facing triangle has positive area here. Only for
// solid, consistently-wound meshes; sprites/room stay double-sided.
// solid, consistently-wound meshes; other materials may stay double-sided.
if (cull && area > 0) {
return
}

View file

@ -46,10 +46,8 @@ export type RenderConfig = {
* short draw distance and the shimmer of far geometry. It also colors pixels
* no triangle covers, so the frame's clear color should match `fog.color`. */
fog: Fog | null
/** Beyond this distance (world units) trees + boulders draw as cheap low-poly
* impostors instead of full geometry, cutting per-triangle work in dense
* views. Kept inside `fog.far` so far detail is already fog-dimmed at the
* switch; `Infinity` disables LOD. */
/** Beyond this distance (world units), chunks draw their cheaper far groups
* instead of near geometry. `Infinity` disables LOD. */
lodDistance: number
}

View file

@ -0,0 +1,123 @@
import type { Mat4 } from "../math/Mat4"
import type { Camera } from "../scene/Camera"
import type { RenderConfig } from "./RenderConfig"
import type { RenderInstance, RenderScene } from "./RenderScene"
export type RenderWorkerInit = {
colorSAB: SharedArrayBuffer
depthSAB: SharedArrayBuffer
width: number
height: number
scene: RenderScene
band: [number, number]
config: RenderConfig
skyStep: number
ctrlSAB: SharedArrayBuffer
cameraSAB: SharedArrayBuffer
viewProjectionSAB: SharedArrayBuffer
visibleChunkSAB: SharedArrayBuffer
instanceIdSAB: SharedArrayBuffer
instanceTransformSAB: SharedArrayBuffer
timesSAB: SharedArrayBuffer
workerIndex: number
}
export type RenderFrameCamera = {
camera: Camera
time: number
}
/** Shared frame-buffer layout used by browser driver and render workers. */
export namespace RenderProtocol {
export const FRAME = 0
export const DONE = 1
export const VISIBLE_CHUNKS = 2
export const VISIBLE_INSTANCES = 3
export const CONTROL_LENGTH = 4
export const CAMERA_X = 0
export const CAMERA_Y = 1
export const CAMERA_Z = 2
export const CAMERA_YAW = 3
export const CAMERA_PITCH = 4
export const CAMERA_FOV = 5
export const CAMERA_TIME = 6
export const CAMERA_LENGTH = 7
export const VIEW_PROJECTION_LENGTH = 16
export const TRANSFORM_FLOATS = 5
export function writeCamera(
output: Float64Array<ArrayBufferLike>,
camera: Camera,
time: number,
): void {
output[CAMERA_X] = camera.position.x
output[CAMERA_Y] = camera.position.y
output[CAMERA_Z] = camera.position.z
output[CAMERA_YAW] = camera.yaw
output[CAMERA_PITCH] = camera.pitch
output[CAMERA_FOV] = camera.fov
output[CAMERA_TIME] = time
}
export function readCamera(input: Float64Array<ArrayBufferLike>): RenderFrameCamera {
return {
camera: {
position: {
x: input[CAMERA_X],
y: input[CAMERA_Y],
z: input[CAMERA_Z],
},
yaw: input[CAMERA_YAW],
pitch: input[CAMERA_PITCH],
fov: input[CAMERA_FOV],
},
time: input[CAMERA_TIME],
}
}
export function writeViewProjection(
output: Float32Array<ArrayBufferLike>,
viewProjection: Mat4,
): void {
output.set(viewProjection)
}
export function writeInstances(
ids: Int32Array<ArrayBufferLike>,
transforms: Float32Array<ArrayBufferLike>,
instances: RenderInstance[],
): number {
const count = Math.min(instances.length, ids.length)
for (let i = 0; i < count; i++) {
const instance = instances[i]
const offset = i * TRANSFORM_FLOATS
ids[i] = instance.prototype
transforms[offset] = instance.x
transforms[offset + 1] = instance.y
transforms[offset + 2] = instance.z
transforms[offset + 3] = instance.heading
transforms[offset + 4] = instance.scale
}
return count
}
export function readInstances(
ids: Int32Array<ArrayBufferLike>,
transforms: Float32Array<ArrayBufferLike>,
count: number,
output: RenderInstance[],
): void {
output.length = 0
for (let i = 0; i < count; i++) {
const offset = i * TRANSFORM_FLOATS
output.push({
prototype: ids[i],
x: transforms[offset],
y: transforms[offset + 1],
z: transforms[offset + 2],
heading: transforms[offset + 3],
scale: transforms[offset + 4],
})
}
}
}

View file

@ -0,0 +1,224 @@
import { Mat4 as Matrix, type Mat4 } from "../math/Mat4"
import type { Vec2 } from "../math/Vec2"
import type { Vec3 } from "../math/Vec3"
import type { Camera } from "../scene/Camera"
import { Sprite } from "../scene/Sprite"
import { Framebuffer, type Framebuffer as Frame } from "./Framebuffer"
import { Frustum } from "./Frustum"
import type { DrawGroup, Material } from "./Material"
import { Rasterizer } from "./Rasterizer"
import type { RenderConfig } from "./RenderConfig"
import { Sky, type SkyConfig } from "./Sky"
import { Chunk, type Chunk as RenderChunk } from "./Chunk"
export type Billboard = {
position: Vec3
size: Vec2
material: Material
}
export type RenderPrototype = {
readonly groups: readonly DrawGroup[]
readonly radius: number
readonly minY: number
readonly maxY: number
}
export type RenderTransform = {
x: number
y: number
z: number
heading: number
scale: number
}
export type RenderInstance = RenderTransform & {
prototype: number
}
/** Clone-safe render projection of a live level. Contains no behavior callbacks or
* game-content registries, so workers need only engine code. */
export type RenderScene = {
readonly chunks: readonly RenderChunk[]
readonly staticGroups: readonly DrawGroup[]
readonly billboards: readonly Billboard[]
readonly prototypes: readonly RenderPrototype[]
readonly maxInstances: number
readonly sky: SkyConfig
}
export namespace RenderScene {
export function visibleChunks(
scene: RenderScene,
viewProjection: Mat4,
): number[] {
const frustum = Frustum.fromViewProj(viewProjection)
const visible: number[] = []
for (let i = 0; i < scene.chunks.length; i++) {
const chunk = scene.chunks[i]
if (
Frustum.intersectsAabb(
frustum,
chunk.minX,
chunk.minY,
chunk.minZ,
chunk.maxX,
chunk.maxY,
chunk.maxZ,
)
) {
visible.push(i)
}
}
return visible
}
export function visibleInstances(
scene: RenderScene,
instances: RenderInstance[],
viewProjection: Mat4,
): RenderInstance[] {
const frustum = Frustum.fromViewProj(viewProjection)
const visible: RenderInstance[] = []
for (const instance of instances) {
const prototype = scene.prototypes[instance.prototype]
if (prototype === undefined) {
continue
}
const radius = prototype.radius * instance.scale
if (
Frustum.intersectsAabb(
frustum,
instance.x - radius,
instance.y + prototype.minY * instance.scale,
instance.z - radius,
instance.x + radius,
instance.y + prototype.maxY * instance.scale,
instance.z + radius,
)
) {
visible.push(instance)
}
}
return visible
}
export function renderBand(
framebuffer: Frame,
scene: RenderScene,
camera: Camera,
viewProjection: Mat4,
visible: number[],
instances: RenderInstance[],
config: RenderConfig,
skyStep: number,
time: number,
y0: number,
y1: number,
): void {
Sky.render(framebuffer, camera, scene.sky, time, skyStep, y0, y1)
drawGroups(framebuffer, scene.staticGroups, viewProjection, config, y0, y1)
for (const index of visible) {
const chunk = scene.chunks[index]
const groups = Chunk.isFar(chunk, camera.position, config.lodDistance)
? chunk.far
: chunk.near
drawGroups(framebuffer, groups, viewProjection, config, y0, y1)
}
for (const billboard of scene.billboards) {
const sprite = {
position: billboard.position,
size: billboard.size,
texture: billboard.material.texture,
}
Rasterizer.draw(
framebuffer,
Sprite.billboard(sprite, camera),
billboard.material.texture,
viewProjection,
config,
billboard.material.cull,
y0,
y1,
)
}
for (const instance of instances) {
const prototype = scene.prototypes[instance.prototype]
if (prototype === undefined) {
continue
}
const modelViewProjection = Matrix.multiply(
viewProjection,
Matrix.compose(
instance.x,
instance.y,
instance.z,
instance.heading,
instance.scale,
),
)
drawGroups(
framebuffer,
prototype.groups,
modelViewProjection,
config,
y0,
y1,
)
}
Framebuffer.quantize(framebuffer, config, y0, y1)
}
export function triangleCount(
scene: RenderScene,
visible: number[],
instances: RenderInstance[],
eye: Vec3,
lodDistance: number,
): number {
let indices = groupIndices(scene.staticGroups) + scene.billboards.length * 6
for (const index of visible) {
const chunk = scene.chunks[index]
indices += groupIndices(
Chunk.isFar(chunk, eye, lodDistance) ? chunk.far : chunk.near,
)
}
for (const instance of instances) {
const prototype = scene.prototypes[instance.prototype]
if (prototype !== undefined) {
indices += groupIndices(prototype.groups)
}
}
return (indices / 3) | 0
}
function drawGroups(
framebuffer: Frame,
groups: readonly DrawGroup[],
matrix: Mat4,
config: RenderConfig,
y0: number,
y1: number,
): void {
for (const group of groups) {
Rasterizer.draw(
framebuffer,
group.mesh,
group.material.texture,
matrix,
config,
group.material.cull,
y0,
y1,
)
}
}
function groupIndices(groups: readonly DrawGroup[]): number {
let count = 0
for (const group of groups) {
count += group.mesh.indices.length
}
return count
}
}

View file

@ -1,26 +1,48 @@
import type { Mesh } from "./Mesh"
import type { RenderPrototype, RenderTransform } from "../render/RenderScene"
import type { Collider } from "../world/Collider"
/** Definition of an **Entity** actor kind: something that lives in the world with
* its own behavior and a live transform (mobs, and later the npc / powerups) -- as
* opposed to a baked, static `Prop`. `State` is the per-instance runtime record the
* behavior mutates; `World` is whatever that behavior reads (e.g. `Terrain`).
*
* Every field is plain data or a module function, so a definition is **imported
* into each context** (main thread + each render worker) rather than structured-
* cloned across the wire -- the per-kind polymorphism is code, not serialized
* state. That's what lets a registry of these stay compatible with the worker
* renderer (only plain instance data ever crosses; behavior is loaded per side). */
export type Entity<State, World> = {
/** Stable tag for the kind (also the texture key today). The registry's order,
* not this string, is what becomes the id packed into the mob SAB. */
name: string
/** Build the canonical local-space mesh once; every instance shares it, differing
* only by its per-frame model matrix. */
build: (mesh: Mesh) => void
/** Advance one instance by `dt` seconds. */
/** Open actor behavior and representation. Concrete definitions are ordinary game
* objects referenced directly by instances. */
export type ActorDefinition<State, World> = {
prototype: RenderPrototype
update: (state: State, dt: number, world: World) => void
/** Local bounding radius (pre-scale) for the per-frame cull AABB. */
boundingRadius: number
/** Local body height (pre-scale) for the top of the stand-on collider. */
bodyHeight: number
transform: (state: State) => RenderTransform
collider?: (state: State, world: World) => Collider | null
}
/** Type-erased live actor. `create` captures concrete state safely, allowing one
* level to hold unrelated actor state types without a content union. */
export type Actor<World> = {
readonly definition: object
readonly prototype: RenderPrototype
readonly updateState: (dt: number, world: World) => void
readonly readTransform: () => RenderTransform
readonly readCollider: (world: World) => Collider | null
}
export namespace Actor {
export function create<State, World>(
definition: ActorDefinition<State, World>,
state: State,
): Actor<World> {
return {
definition,
prototype: definition.prototype,
updateState: (dt, world) => definition.update(state, dt, world),
readTransform: () => definition.transform(state),
readCollider: (world) => definition.collider?.(state, world) ?? null,
}
}
export function update<World>(actor: Actor<World>, dt: number, world: World): void {
actor.updateState(dt, world)
}
export function transform<World>(actor: Actor<World>): RenderTransform {
return actor.readTransform()
}
export function collider<World>(actor: Actor<World>, world: World): Collider | null {
return actor.readCollider(world)
}
}

View file

@ -0,0 +1,80 @@
import { STRIDE, type Mesh } from "./Mesh"
type Corner = [number, number, number]
export namespace MeshBuilder {
export function quad(
mesh: Mesh,
a: Corner,
b: Corner,
c: Corner,
d: Corner,
uScale: number,
vScale: number,
): void {
const base = mesh.verts.length / STRIDE
mesh.verts.push(
a[0],
a[1],
a[2],
0,
0,
b[0],
b[1],
b[2],
uScale,
0,
c[0],
c[1],
c[2],
uScale,
vScale,
d[0],
d[1],
d[2],
0,
vScale,
)
mesh.indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
}
export function slab(
mesh: Mesh,
x0: number,
x1: number,
z0: number,
z1: number,
y0: number,
y1: number,
tilesPerUnit: number,
): void {
const dx = (x1 - x0) * tilesPerUnit
const dz = (z1 - z0) * tilesPerUnit
const dy = (y1 - y0) * tilesPerUnit
quad(mesh, [x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1], dx, dz)
quad(mesh, [x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], dx, dy)
quad(mesh, [x1, y0, z1], [x0, y0, z1], [x0, y1, z1], [x1, y1, z1], dx, dy)
quad(mesh, [x0, y0, z1], [x0, y0, z0], [x0, y1, z0], [x0, y1, z1], dz, dy)
quad(mesh, [x1, y0, z0], [x1, y0, z1], [x1, y1, z1], [x1, y1, z0], dz, dy)
}
export function box(
mesh: Mesh,
centerX: number,
centerZ: number,
half: number,
base: number,
height: number,
): void {
const x0 = centerX - half
const x1 = centerX + half
const z0 = centerZ - half
const z1 = centerZ + half
const y1 = base + height
quad(mesh, [x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1], 1, 1)
quad(mesh, [x0, base, z0], [x1, base, z0], [x1, y1, z0], [x0, y1, z0], 1, 1)
quad(mesh, [x1, base, z1], [x0, base, z1], [x0, y1, z1], [x1, y1, z1], 1, 1)
quad(mesh, [x1, base, z0], [x1, base, z1], [x1, y1, z1], [x1, y1, z0], 1, 1)
quad(mesh, [x0, base, z1], [x0, base, z0], [x0, y1, z0], [x0, y1, z1], 1, 1)
}
}

38
engine/scene/Prefab.ts Normal file
View file

@ -0,0 +1,38 @@
import type { Vec3 } from "../math/Vec3"
import type { MeshBatch } from "../render/ChunkBuilder"
import type { Collider } from "../world/Collider"
/** Static content recipe. Concrete game prefabs implement this engine contract and
* are referenced as objects, never through a closed content-kind registry. */
export type Prefab<State> = {
position: (state: State) => Vec3
bakeNear: (state: State, batch: MeshBatch) => void
bakeFar?: (state: State, batch: MeshBatch) => void
collider?: (state: State) => Collider | null
}
/** Type-erased placed prefab consumed during level compilation only. */
export type PlacedPrefab = {
position: Vec3
bakeNear: (batch: MeshBatch) => void
bakeFar?: (batch: MeshBatch) => void
collider: Collider | null
}
export namespace Prefab {
export function place<State>(
definition: Prefab<State>,
state: State,
): PlacedPrefab {
const bakeFar = definition.bakeFar
return {
position: definition.position(state),
bakeNear: (batch) => definition.bakeNear(state, batch),
bakeFar:
bakeFar === undefined
? undefined
: (batch) => bakeFar(state, batch),
collider: definition.collider?.(state) ?? null,
}
}
}

View file

@ -0,0 +1,89 @@
import type { Vec3 } from "../math/Vec3"
import { CollisionWorld, type CollisionWorld as World } from "./CollisionWorld"
export type Character = {
position: Vec3
yaw: number
velocityY: number
onGround: boolean
}
export type CharacterInput = {
forward: number
right: number
jump: boolean
run: boolean
}
export type CharacterConfig = {
radius: number
speed: number
runMultiplier: number
gravity: number
jumpSpeed: number
eyeHeight: number
}
export namespace CharacterController {
export function update(
character: Character,
input: CharacterInput,
dt: number,
world: World,
config: CharacterConfig,
): void {
if (input.jump && character.onGround) {
character.velocityY = config.jumpSpeed
character.onGround = false
}
const steps = moveSubsteps(input, dt, config)
for (let i = 0; i < steps; i++) {
moveHorizontal(character, input, dt / steps, config)
CollisionWorld.pushOut(world, character.position, config.radius)
}
character.velocityY -= config.gravity * dt
character.position.y += character.velocityY * dt
const ground = CollisionWorld.groundHeight(world, character.position)
if (character.position.y <= ground) {
character.position.y = ground
character.velocityY = 0
character.onGround = true
} else {
character.onGround = false
}
}
function moveSubsteps(
input: CharacterInput,
dt: number,
config: CharacterConfig,
): number {
const distance =
config.speed *
runFactor(input, config) *
dt *
Math.hypot(input.forward, input.right)
return Math.max(1, Math.ceil(distance / config.radius))
}
function moveHorizontal(
character: Character,
input: CharacterInput,
dt: number,
config: CharacterConfig,
): void {
const speed = config.speed * runFactor(input, config) * dt
const forwardX = Math.sin(character.yaw)
const forwardZ = -Math.cos(character.yaw)
const rightX = Math.cos(character.yaw)
const rightZ = Math.sin(character.yaw)
character.position.x +=
(forwardX * input.forward + rightX * input.right) * speed
character.position.z +=
(forwardZ * input.forward + rightZ * input.right) * speed
}
function runFactor(input: CharacterInput, config: CharacterConfig): number {
return input.run ? config.runMultiplier : 1
}
}

116
engine/world/Collider.ts Normal file
View file

@ -0,0 +1,116 @@
import type { Vec3 } from "../math/Vec3"
/** A 2.5D box: horizontal footprint plus top height. */
export type BoxCollider = {
shape: "box"
minX: number
maxX: number
minZ: number
maxZ: number
top: number
standable: boolean
}
/** A 2.5D circle: horizontal footprint plus top height. */
export type CircleCollider = {
shape: "circle"
x: number
z: number
radius: number
top: number
standable: boolean
}
/** Finite engine collision capabilities, not game-content identity. */
export type Collider = BoxCollider | CircleCollider
export namespace Collider {
export function centerX(collider: Collider): number {
return collider.shape === "circle"
? collider.x
: (collider.minX + collider.maxX) * 0.5
}
export function centerZ(collider: Collider): number {
return collider.shape === "circle"
? collider.z
: (collider.minZ + collider.maxZ) * 0.5
}
export function contains(collider: Collider, x: number, z: number): boolean {
if (collider.shape === "circle") {
const dx = x - collider.x
const dz = z - collider.z
return dx * dx + dz * dz <= collider.radius * collider.radius
}
return (
x >= collider.minX &&
x <= collider.maxX &&
z >= collider.minZ &&
z <= collider.maxZ
)
}
/** Push a horizontal player circle out of one collider. */
export function pushOut(
collider: Collider,
position: Vec3,
radius: number,
): void {
if (collider.shape === "circle") {
pushFromCircle(position, radius, collider)
return
}
pushFromBox(position, radius, collider)
}
function pushFromBox(position: Vec3, radius: number, box: BoxCollider): void {
const cx = Math.max(box.minX, Math.min(box.maxX, position.x))
const cz = Math.max(box.minZ, Math.min(box.maxZ, position.z))
const dx = position.x - cx
const dz = position.z - cz
const distanceSquared = dx * dx + dz * dz
if (distanceSquared >= radius * radius) {
return
}
if (distanceSquared > 1e-6) {
const distance = Math.sqrt(distanceSquared)
const push = (radius - distance) / distance
position.x += dx * push
position.z += dz * push
return
}
const left = position.x - box.minX
const right = box.maxX - position.x
const near = position.z - box.minZ
const far = box.maxZ - position.z
const nearest = Math.min(left, right, near, far)
if (nearest === left) {
position.x = box.minX - radius
} else if (nearest === right) {
position.x = box.maxX + radius
} else if (nearest === near) {
position.z = box.minZ - radius
} else {
position.z = box.maxZ + radius
}
}
function pushFromCircle(
position: Vec3,
radius: number,
circle: CircleCollider,
): void {
const dx = position.x - circle.x
const dz = position.z - circle.z
const reach = radius + circle.radius
const distanceSquared = dx * dx + dz * dz
if (distanceSquared >= reach * reach || distanceSquared < 1e-6) {
return
}
const distance = Math.sqrt(distanceSquared)
const push = (reach - distance) / distance
position.x += dx * push
position.z += dz * push
}
}

View file

@ -0,0 +1,62 @@
import type { Vec3 } from "../math/Vec3"
import { Collider, type Collider as ColliderShape } from "./Collider"
import type { Terrain } from "./Terrain"
export type CollisionWorld = {
terrain: Terrain
staticColliders: ColliderShape[]
dynamicColliders: ColliderShape[]
}
export namespace CollisionWorld {
export function create(
terrain: Terrain,
staticColliders: ColliderShape[],
): CollisionWorld {
return { terrain, staticColliders, dynamicColliders: [] }
}
export function pushOut(
world: CollisionWorld,
position: Vec3,
radius: number,
): void {
pushFrom(world.staticColliders, position, radius)
pushFrom(world.dynamicColliders, position, radius)
}
export function groundHeight(world: CollisionWorld, position: Vec3): number {
let ground = world.terrain.heightAt(position.x, position.z)
ground = standingHeight(world.staticColliders, position, ground)
return standingHeight(world.dynamicColliders, position, ground)
}
function pushFrom(
colliders: ColliderShape[],
position: Vec3,
radius: number,
): void {
for (const collider of colliders) {
if (position.y < collider.top - 0.01) {
Collider.pushOut(collider, position, radius)
}
}
}
function standingHeight(
colliders: ColliderShape[],
position: Vec3,
initial: number,
): number {
let ground = initial
for (const collider of colliders) {
if (
collider.standable &&
Collider.contains(collider, position.x, position.z)
) {
ground = Math.max(ground, collider.top)
}
}
return ground
}
}

123
engine/world/Level.ts Normal file
View file

@ -0,0 +1,123 @@
import {
Actor,
type Actor as RuntimeActor,
} from "../scene/Actor"
import { Collider, type Collider as CollisionShape } from "./Collider"
import {
CollisionWorld,
type CollisionWorld as Collision,
} from "./CollisionWorld"
import type { Terrain } from "./Terrain"
import {
RenderScene,
type Billboard,
type RenderInstance,
type RenderPrototype,
type RenderScene as Scene,
} from "../render/RenderScene"
import type { Chunk } from "../render/Chunk"
import type { DrawGroup } from "../render/Material"
import type { SkyConfig } from "../render/Sky"
import type { Mat4 } from "../math/Mat4"
import type { Vec3 } from "../math/Vec3"
export type LevelDefinition<World> = {
terrain: Terrain
actorWorld: World
actors: readonly RuntimeActor<World>[]
staticColliders: CollisionShape[]
staticGroups: DrawGroup[]
chunks: Chunk[]
billboards: Billboard[]
sky: SkyConfig
}
/** Live engine world. Behavior-bearing actors stay here on the main thread; only
* `render` is clone-safe and sent to workers. */
export type Level<World> = {
readonly terrain: Terrain
readonly actorWorld: World
readonly actors: readonly RuntimeActor<World>[]
readonly collision: Collision
readonly render: Scene
}
const prototypeIndexes = new WeakMap<object, ReadonlyMap<object, number>>()
export namespace Level {
export function create<World>(definition: LevelDefinition<World>): Level<World> {
const actors = Object.freeze([...definition.actors])
const prototypes: RenderPrototype[] = []
const prototypeIndex = new Map<object, number>()
for (const actor of actors) {
if (!prototypeIndex.has(actor.definition)) {
prototypeIndex.set(actor.definition, prototypes.length)
prototypes.push(actor.prototype)
}
}
const level: Level<World> = {
terrain: definition.terrain,
actorWorld: definition.actorWorld,
actors,
collision: CollisionWorld.create(
definition.terrain,
definition.staticColliders,
),
render: Object.freeze({
chunks: Object.freeze([...definition.chunks]),
staticGroups: Object.freeze([...definition.staticGroups]),
billboards: Object.freeze([...definition.billboards]),
prototypes: Object.freeze(prototypes),
maxInstances: actors.length,
sky: definition.sky,
}),
}
prototypeIndexes.set(level, prototypeIndex)
return level
}
export function update<World>(level: Level<World>, dt: number): void {
for (const actor of level.actors) {
Actor.update(actor, dt, level.actorWorld)
}
}
export function visibleInstances<World>(
level: Level<World>,
viewProjection: Mat4,
): RenderInstance[] {
const prototypeIndex = prototypeIndexes.get(level)
if (prototypeIndex === undefined) {
throw new Error("level was not created by Level.create")
}
const instances: RenderInstance[] = []
for (const actor of level.actors) {
const prototype = prototypeIndex.get(actor.definition)
if (prototype !== undefined) {
instances.push({ prototype, ...Actor.transform(actor) })
}
}
return RenderScene.visibleInstances(level.render, instances, viewProjection)
}
export function refreshActorColliders<World>(
level: Level<World>,
focus: Vec3,
range: number,
): void {
const dynamic = level.collision.dynamicColliders
dynamic.length = 0
const rangeSquared = range * range
for (const actor of level.actors) {
const collider = Actor.collider(actor, level.actorWorld)
if (collider === null) {
continue
}
const dx = Collider.centerX(collider) - focus.x
const dz = Collider.centerZ(collider) - focus.z
if (dx * dx + dz * dz <= rangeSquared) {
dynamic.push(collider)
}
}
}
}

121
engine/world/Terrain.ts Normal file
View file

@ -0,0 +1,121 @@
import { STRIDE, type Mesh } from "../scene/Mesh"
/** A bounded ground surface. Implementations may be procedural, sampled, or
* loaded; callers only depend on world-space height sampling. */
export type Terrain = {
minX: number
minZ: number
maxX: number
maxZ: number
heightAt: (x: number, z: number) => number
}
/** Parameters for the built-in rolling terrain generator. Values describe the
* surface only; level-specific holes and materials belong to level data. */
export type RollingTerrainConfig = {
inner: number
outer: number
blend: number
amplitude: number
frequency: number
peakHeight: number
peakFrequency: number
peakStart: number
}
export namespace Terrain {
/** Built-in square world with a flat center, rolling hills, and edge ridges. */
export function rolling(config: RollingTerrainConfig): Terrain {
return {
minX: -config.outer,
minZ: -config.outer,
maxX: config.outer,
maxZ: config.outer,
heightAt(x, z) {
const r = Math.max(Math.abs(x), Math.abs(z))
if (r <= config.inner) {
return 0
}
const rise = smoothstep(config.inner, config.inner + config.blend, r)
const hills = config.amplitude * bumps(x, z, config.frequency)
const k = Math.min(
1,
(r - config.inner) / (config.outer - config.inner),
)
const peaks =
config.peakHeight *
ridges(x, z, config.peakFrequency) *
smoothstep(config.peakStart, 1, k)
return rise * (hills + peaks)
},
}
}
export function height(terrain: Terrain, x: number, z: number): number {
return terrain.heightAt(x, z)
}
/** Append one sampled heightfield patch. `include` is level policy evaluated at
* each quad center, allowing arbitrary holes without teaching terrain what
* occupies them. */
export function patch(
terrain: Terrain,
mesh: Mesh,
x0: number,
z0: number,
x1: number,
z1: number,
cols: number,
rows: number,
uvScale: number,
include?: (x: number, z: number) => boolean,
): void {
const base = mesh.verts.length / STRIDE
const dx = (x1 - x0) / cols
const dz = (z1 - z0) / rows
const rowLength = cols + 1
for (let row = 0; row <= rows; row++) {
const z = z0 + row * dz
for (let col = 0; col <= cols; col++) {
const x = x0 + col * dx
mesh.verts.push(x, terrain.heightAt(x, z), z, x * uvScale, z * uvScale)
}
}
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const cx = x0 + (col + 0.5) * dx
const cz = z0 + (row + 0.5) * dz
if (include !== undefined && !include(cx, cz)) {
continue
}
const p = base + row * rowLength + col
mesh.indices.push(
p,
p + rowLength + 1,
p + 1,
p,
p + rowLength,
p + rowLength + 1,
)
}
}
}
function bumps(x: number, z: number, frequency: number): number {
const a = Math.sin(x * frequency) * Math.cos(z * frequency)
const b = Math.sin((x + z) * frequency * 0.5 + 1.7) * 0.5
return (a + b + 1.5) / 3
}
function ridges(x: number, z: number, frequency: number): number {
const n =
Math.sin(x * frequency + 1.3) * Math.cos(z * frequency - 0.7) * 0.7 +
Math.sin((x + z) * frequency * 0.6 + 2.5) * 0.3
return 1 - Math.abs(n)
}
function smoothstep(a: number, b: number, x: number): number {
const t = Math.max(0, Math.min(1, (x - a) / (b - a || 1e-4)))
return t * t * (3 - 2 * t)
}
}

View file

@ -1,106 +0,0 @@
import { STRIDE, type Mesh } from "../engine/scene/Mesh"
/** A procedural heightfield surrounding the room. It is the single source of
* ground height: the outdoor mesh is built from it and the player stands on the
* same `height` samples, so what you see and what you collide with agree. The
* center (out to `inner`) is a flat clearing where the room sits; from there the
* land rolls outward and ramps up into tall peaks at the far edge. Every field
* is a live knob -- edit them in the level to reshape the world. */
export type Terrain = {
/** Half-extent of the flat central clearing (the room lives here); height 0. */
inner: number
/** World half-extent. Peaks ramp up toward this outer rim. */
outer: number
/** Ease-up distance just outside `inner`, so the clearing meets the hills with
* a slope instead of a wall. */
blend: number
/** Rolling-hill height across the open ground. */
amplitude: number
/** Rolling-hill frequency (low = broad hills over the big world). */
frequency: number
/** Extra height of the mountains near the edge -- make this big for peaks. */
peakHeight: number
/** Mountain frequency (low = few, massive ridges). */
peakFrequency: number
/** Fraction of the way out (0..1) where the peaks begin rising. */
peakStart: number
}
export namespace Terrain {
/** Ground height at world (x, z). 0 inside the clearing, rolling hills beyond,
* ramping into peaks toward the edge. Uses a square (Chebyshev) radius so the
* clearing is a square that lines up with the square room. */
export function height(t: Terrain, x: number, z: number): number {
const r = Math.max(Math.abs(x), Math.abs(z))
if (r <= t.inner) {
return 0
}
const rise = smoothstep(t.inner, t.inner + t.blend, r)
const hills = t.amplitude * bumps(x, z, t.frequency)
const k = Math.min(1, (r - t.inner) / (t.outer - t.inner))
const peaks = t.peakHeight * ridges(x, z, t.peakFrequency) * smoothstep(t.peakStart, 1, k)
return rise * (hills + peaks)
}
/** Append one ground patch: a `cols`x`rows` heightfield grid over the rectangle
* [x0,x1] x [z0,z1], each vertex lifted onto the heightfield. Quads whose
* center is inside the clearing are skipped (the room floor's hole). UVs use
* world position * `uvScale`, so neighboring patches tile seamlessly. Callers
* keep the spacing uniform and cell edges aligned, so shared edges weld with
* no cracks. Used to build the terrain per spatial chunk. */
export function patch(
t: Terrain,
mesh: Mesh,
x0: number,
z0: number,
x1: number,
z1: number,
cols: number,
rows: number,
uvScale: number,
): void {
const base = mesh.verts.length / STRIDE
const dx = (x1 - x0) / cols
const dz = (z1 - z0) / rows
const rowLen = cols + 1
for (let i = 0; i <= rows; i++) {
const z = z0 + i * dz
for (let j = 0; j <= cols; j++) {
const x = x0 + j * dx
mesh.verts.push(x, height(t, x, z), z, x * uvScale, z * uvScale)
}
}
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
const cx = x0 + (j + 0.5) * dx
const cz = z0 + (i + 0.5) * dz
if (Math.max(Math.abs(cx), Math.abs(cz)) < t.inner) {
continue
}
const p = base + i * rowLen + j
// Wound so the surface faces up/out, matching the backface-cull sign.
mesh.indices.push(p, p + rowLen + 1, p + 1, p, p + rowLen, p + rowLen + 1)
}
}
}
/** Rolling hills in 0..1, always non-negative so the ground never dips below
* the clearing. */
function bumps(x: number, z: number, f: number): number {
const a = Math.sin(x * f) * Math.cos(z * f)
const b = Math.sin((x + z) * f * 0.5 + 1.7) * 0.5
return (a + b + 1.5) / 3
}
/** Ridged noise in 0..1: crests where the field crosses zero give sharp
* mountain ridgelines rather than round blobs. */
function ridges(x: number, z: number, f: number): number {
const n = Math.sin(x * f + 1.3) * Math.cos(z * f - 0.7) * 0.7 + Math.sin((x + z) * f * 0.6 + 2.5) * 0.3
return 1 - Math.abs(n)
}
function smoothstep(a: number, b: number, x: number): number {
const t = Math.max(0, Math.min(1, (x - a) / (b - a || 1e-4)))
return t * t * (3 - 2 * t)
}
}

View file

@ -1,5 +1,7 @@
import type { Vec3 } from "../../engine/math/Vec3"
import { STRIDE, type Mesh } from "../../engine/scene/Mesh"
import type { Material } from "../../engine/render/Material"
import type { Prefab } from "../../engine/scene/Prefab"
const TAU = Math.PI * 2
@ -22,8 +24,35 @@ export type Boulder = {
* field of boulders batches into a single draw call.
*/
export namespace Boulder {
export function create(material: Material): Prefab<Boulder> {
return {
position: (boulder) => boulder.position,
bakeNear: (boulder, batch) => build(boulder, batch.mesh(material)),
bakeFar: (boulder, batch) =>
build(boulder, batch.mesh(material), "impostor"),
collider(boulder) {
if (boulder.radius <= 0.7) {
return null
}
return {
shape: "box",
minX: boulder.position.x - boulder.radius,
maxX: boulder.position.x + boulder.radius,
minZ: boulder.position.z - boulder.radius,
maxZ: boulder.position.z + boulder.radius,
top: boulder.position.y + boulder.radius * 0.7,
standable: false,
}
},
}
}
/** `lod` "impostor" bakes a coarser rock (fewer facets) for far chunks. */
export function build(boulder: Boulder, mesh: Mesh, lod: "full" | "impostor" = "full"): void {
export function build(
boulder: Boulder,
mesh: Mesh,
lod: "full" | "impostor" = "full",
): void {
const rand = rng(boulder.seed)
const seg = lod === "impostor" ? 4 : 5
const rings = lod === "impostor" ? 2 : 4
@ -67,7 +96,11 @@ export namespace Boulder {
/** Per-vertex radial scale in ~0.72..1.14 for a chunky, angular surface. The
* longitude seam (last column == first) and each pole row (one shared value)
* match so the mesh stays closed. */
function jitterGrid(seg: number, rings: number, rand: () => number): number[][] {
function jitterGrid(
seg: number,
rings: number,
rand: () => number,
): number[][] {
const grid: number[][] = []
for (let ir = 0; ir <= rings; ir++) {
const pole = ir === 0 || ir === rings

View file

@ -1,5 +1,7 @@
import type { Vec3 } from "../../engine/math/Vec3"
import { STRIDE, type Mesh } from "../../engine/scene/Mesh"
import type { Material } from "../../engine/render/Material"
import type { Prefab } from "../../engine/scene/Prefab"
const TAU = Math.PI * 2
@ -17,6 +19,13 @@ export type Bush = {
* wound outward, so backface culling is safe. `build` appends into a shared
* (leaf-textured) mesh. */
export namespace Bush {
export function create(material: Material): Prefab<Bush> {
return {
position: (bush) => bush.position,
bakeNear: (bush, batch) => build(bush, batch.mesh(material)),
}
}
export function build(bush: Bush, mesh: Mesh): void {
const rand = rng(bush.seed)
// A handful of smaller overlapping lumps reads as a soft shrub; one big
@ -34,7 +43,14 @@ export namespace Bush {
}
/** A small lumpy low-poly sphere, wound outward (matches the oak canopy blob). */
function blob(mesh: Mesh, cx: number, cy: number, cz: number, radius: number, rand: () => number): void {
function blob(
mesh: Mesh,
cx: number,
cy: number,
cz: number,
radius: number,
rand: () => number,
): void {
const seg = 6
const rings = 4
const start = mesh.verts.length / STRIDE

View file

@ -1,17 +1,19 @@
import type { Vec3 } from "../../engine/math/Vec3"
import { Mesh } from "../../engine/scene/Mesh"
import type { Material } from "../../engine/render/Material"
import type { Prefab } from "../../engine/scene/Prefab"
const TAU = Math.PI * 2
/** Flower bloom color, indexing a region of the `flower` texture atlas. */
export type FlowerColor = "white" | "red" | "yellow"
/** Concrete atlas style referenced directly by flower instances. */
export type FlowerStyle = { bloomUv: [number, number] }
/** A single small flower: a thin crossed-quad stem plus a shallow fan of petals.
* Tiny, so it is drawn double-sided (no backface cull) and carries no collider.
* `size` is roughly its height; `seed` jitters the petals. */
export type Flower = {
position: Vec3
color: FlowerColor
style: FlowerStyle
size: number
seed: number
}
@ -23,16 +25,21 @@ export type Flower = {
* or draw call. `build` appends into one shared flower mesh.
*/
export namespace Flower {
/** uv center of each bloom color's atlas region (tile units). */
const BLOOM_UV: Record<FlowerColor, [number, number]> = {
white: [0.75, 0.25],
red: [0.25, 0.75],
yellow: [0.75, 0.75],
}
export const white: FlowerStyle = { bloomUv: [0.75, 0.25] }
export const red: FlowerStyle = { bloomUv: [0.25, 0.75] }
export const yellow: FlowerStyle = { bloomUv: [0.75, 0.75] }
/** uv center of the green stem region. */
const STEM_U = 0.25
const STEM_V = 0.25
export function create(material: Material): Prefab<Flower> {
return {
position: (flower) => flower.position,
bakeNear: (flower, batch) => build(flower, batch.mesh(material)),
}
}
export function build(flower: Flower, mesh: Mesh): void {
const rand = rng(flower.seed)
const p = flower.position
@ -43,14 +50,21 @@ export namespace Flower {
stem(mesh, p.x, p.y, p.z, bloomY, w, 0)
stem(mesh, p.x, p.y, p.z, bloomY, 0, w)
// Bloom: a shallow fan of petals, center raised a touch so it domes.
const [bu, bv] = BLOOM_UV[flower.color]
const [bu, bv] = flower.style.bloomUv
const rad = flower.size * 0.38
const center = Mesh.push(mesh, p.x, bloomY + rad * 0.3, p.z, bu, bv)
const ring = center + 1
const petals = 5
for (let i = 0; i <= petals; i++) {
const angle = (i / petals) * TAU + rand() * 0.4
Mesh.push(mesh, p.x + Math.cos(angle) * rad, bloomY, p.z + Math.sin(angle) * rad, bu, bv)
Mesh.push(
mesh,
p.x + Math.cos(angle) * rad,
bloomY,
p.z + Math.sin(angle) * rad,
bu,
bv,
)
}
for (let i = 0; i < petals; i++) {
mesh.indices.push(center, ring + i, ring + i + 1)
@ -58,7 +72,15 @@ export namespace Flower {
}
/** A thin vertical quad from the ground to `y1`, width along (dx, dz). */
function stem(mesh: Mesh, x: number, y0: number, z: number, y1: number, dx: number, dz: number): void {
function stem(
mesh: Mesh,
x: number,
y0: number,
z: number,
y1: number,
dx: number,
dz: number,
): void {
const a = Mesh.push(mesh, x - dx, y0, z - dz, STEM_U, STEM_V)
const b = Mesh.push(mesh, x + dx, y0, z + dz, STEM_U, STEM_V)
const c = Mesh.push(mesh, x + dx, y1, z + dz, STEM_U, STEM_V)

View file

@ -1,27 +1,14 @@
import type { Terrain } from "../Terrain"
import type { Vec3 } from "../../engine/math/Vec3"
import type { Mesh } from "../../engine/scene/Mesh"
import type { Entity } from "../../engine/scene/Actor"
import { frog } from "./mobs/Frog"
import { bee } from "./mobs/Bee"
import { robin } from "./mobs/Robin"
import type { Actor } from "../../engine/scene/Actor"
import type { RenderTransform } from "../../engine/render/RenderScene"
import type { BoxCollider } from "../../engine/world/Collider"
import type { Terrain } from "../../engine/world/Terrain"
/** A roaming creature drawn as a moving low-poly mesh (unlike the static baked
* world). Each kind is an `Entity` definition (geometry + behavior + bounds) living
* in its own module under `mobs/`; this file just assembles them into a registry
* and exposes a thin per-kind dispatch. Adding a kind = add a `mobs/<Kind>.ts` +
* one entry in `MOB_KINDS`/`DEFS`.
*
* A mob's geometry is a **canonical local-space mesh** built once per kind (front =
* +Z, frog/robin feet / bee body at the origin); the live `position`/`heading`/
* `scale` are turned into a per-frame model matrix by the renderer. All wander
* state lives on the instance so `update` is a pure stepping function of the mob +
* dt (deterministic via the evolving `seed`), which keeps the sim on the main
* thread and cloneable-free. */
export type MobKind = "frog" | "bee" | "robin"
/** Runtime actor using shared roaming-creature state. Concrete definitions are
* direct object references supplied by Frog, Bee, Robin, or future content. */
export type Mob = Actor<Terrain>
export type Mob = {
kind: MobKind
export type MobState = {
/** Leash anchor (where it was scattered); wandering is pulled back toward it. */
home: Vec3
/** Live feet-center (frog/robin) / body-center (bee), advanced each frame. */
@ -39,46 +26,39 @@ export type Mob = {
vy: number
/** Countdown to the next decision (frog/robin: next hop; bee: next heading change). */
timer: number
/** Per-kind scratch clock: the bee's hover-bob phase; the robin's remaining
/** Behavior scratch clock: the bee's hover-bob phase; the robin's remaining
* powered-flight cruise time (>0 while gliding between perches). */
phase: number
/** Frog/robin: resting on the ground vs airborne (a hop or a flight). */
grounded: boolean
}
/** Canonical kind order. **The index is the id packed into the mob SAB** (see
* renderer/worker), so this order must be identical in every context and must not
* change under existing kinds -- `mobs.test.ts` guards it. Append new kinds. */
export const MOB_KINDS: MobKind[] = ["frog", "bee", "robin"]
/** The per-kind `Entity` definitions, one module each. Imported (not cloned) into
* whatever context uses it, so it works the same on the main thread and in workers. */
const DEFS: Record<MobKind, Entity<Mob, Terrain>> = { frog, bee, robin }
export namespace Mob {
/** The definition for a kind (geometry, behavior, bounds). */
export function def(kind: MobKind): Entity<Mob, Terrain> {
return DEFS[kind]
export function transform(state: MobState): RenderTransform {
return {
x: state.position.x,
y: state.position.y,
z: state.position.z,
heading: state.heading,
scale: state.scale,
}
}
/** Advance one mob by `dt` seconds, sampling `terrain` for ground height. */
export function update(mob: Mob, dt: number, terrain: Terrain): void {
DEFS[mob.kind].update(mob, dt, terrain)
export function collider(
state: MobState,
radius: number,
height: number,
standable: boolean,
): BoxCollider {
const half = radius * state.scale * 0.7
return {
shape: "box",
minX: state.position.x - half,
maxX: state.position.x + half,
minZ: state.position.z - half,
maxZ: state.position.z + half,
top: state.position.y + height * state.scale,
standable,
}
/** Append the canonical local-space mesh for `kind` into `mesh` (once per kind at
* load; every instance shares it, differing only by transform). */
export function build(kind: MobKind, mesh: Mesh): void {
DEFS[kind].build(mesh)
}
/** Local bounding radius (pre-scale), for building the per-frame cull AABB. */
export function boundingRadius(kind: MobKind): number {
return DEFS[kind].boundingRadius
}
/** Local body height (pre-scale), for the top of the stand-on collider. */
export function bodyHeight(kind: MobKind): number {
return DEFS[kind].bodyHeight
}
}

View file

@ -1,54 +1,11 @@
import type { Vec3 } from "../../engine/math/Vec3"
import type { Mesh } from "../../engine/scene/Mesh"
import { oak } from "./trees/Oak"
import { spruce } from "./trees/Spruce"
import { birch } from "./trees/Birch"
export type TreeKind = "oak" | "spruce" | "birch"
/** One procedural tree instance. `growth` 0..1 runs sapling -> full grown: it scales
* height and girth and adds canopy blobs / tiers. `seed` drives the per-tree random
* wobble so a forest doesn't look cloned. */
export type Tree = {
kind: TreeKind
/** Trunk base, sitting on the ground. */
position: Vec3
growth: number
seed: number
}
/** Definition of a tree species: which chunk materials its trunk + foliage bake
* into, plus how to append its geometry. Each lives in its own `trees/<Kind>.ts`
* module (silhouette carries the species read); this file just assembles them.
* `trunk`/`foliage` are chunk-material keys (see `level.ts` `ChunkMaterials`):
* oak/spruce use the brown `bark`, birch the white `birch`; foliage is the oak
* `leaf` or spruce `needle`. */
export type TreeSpecies = {
kind: TreeKind
trunk: string
foliage: string
build: (tree: Tree, trunk: Mesh, foliage: Mesh, lod: "full" | "impostor") => void
}
/** All tree species (also the placement roll's palette). Trees are baked at load,
* not shipped per frame, so this order isn't an id contract like `MOB_KINDS` -- but
* keeping it lets placement + tests stay registry-driven. */
export const TREE_KINDS: TreeKind[] = ["oak", "spruce", "birch"]
/** The per-species definitions, one module each. Imported (not cloned) wherever
* used, so it works the same on the main thread and in workers. */
const SPECIES: Record<TreeKind, TreeSpecies> = { oak, spruce, birch }
export namespace Tree {
/** The species definition for a kind (its trunk/foliage materials + geometry). */
export function species(kind: TreeKind): TreeSpecies {
return SPECIES[kind]
}
/** Append one tree into the caller-provided `trunk` + `foliage` meshes (which the
* caller selects from the species' `trunk`/`foliage` material keys). `lod`
* "impostor" bakes a much cheaper stand-in for far chunks; "full" is up close. */
export function build(tree: Tree, trunk: Mesh, foliage: Mesh, lod: "full" | "impostor" = "full"): void {
SPECIES[tree.kind].build(tree, trunk, foliage, lod)
}
}

View file

@ -1,15 +1,28 @@
import { Terrain } from "../../Terrain"
import type { Mesh } from "../../../engine/scene/Mesh"
import type { Mob } from "../Mob"
import type { Entity } from "../../../engine/scene/Actor"
import { Terrain, type Terrain as Ground } from "../../../engine/world/Terrain"
import { Mesh, type Mesh as Geometry } from "../../../engine/scene/Mesh"
import type { Material } from "../../../engine/render/Material"
import type { ActorDefinition } from "../../../engine/scene/Actor"
import { Mob, type MobState } from "../Mob"
import { ellipsoid, nextRand, ovoidZ, wanderHeading, wing } from "./mobkit"
export const bee: Entity<Mob, Terrain> = {
name: "bee",
build,
export type Bee = ActorDefinition<MobState, Ground>
export namespace Bee {
export function create(material: Material): Bee {
const mesh = Mesh.create()
build(mesh)
return {
prototype: {
groups: [{ mesh, material }],
radius: 0.6,
minY: -0.5,
maxY: 1,
},
update,
boundingRadius: 0.5,
bodyHeight: 0.5,
transform: Mob.transform,
collider: (state) => Mob.collider(state, 0.5, 0.5, false),
}
}
}
const LEASH = 6
@ -20,7 +33,7 @@ const HOVER = 1.1
const BOB_AMP = 0.18
const BOB_FREQ = 3
function build(mesh: Mesh): void {
function build(mesh: Geometry): void {
// Fore-aft ovoid body striped along its length, a dark head at the front, two
// pale wings. UVs: bee texture is stripe bands (left), head-dark (mid), wing-pale
// (right); the body maps v along z so the stripes band across it.
@ -30,7 +43,7 @@ function build(mesh: Mesh): void {
wing(mesh, -1, 0.83, 0.99, 0, 1)
}
function update(mob: Mob, dt: number, terrain: Terrain): void {
function update(mob: MobState, dt: number, terrain: Ground): void {
mob.phase += dt
mob.timer -= dt
if (mob.timer <= 0) {

View file

@ -1,17 +1,30 @@
import { Terrain } from "../../Terrain"
import type { Mesh } from "../../../engine/scene/Mesh"
import type { Mob } from "../Mob"
import type { Entity } from "../../../engine/scene/Actor"
import { Terrain, type Terrain as Ground } from "../../../engine/world/Terrain"
import { Mesh, type Mesh as Geometry } from "../../../engine/scene/Mesh"
import type { Material } from "../../../engine/render/Material"
import type { ActorDefinition } from "../../../engine/scene/Actor"
import { Mob, type MobState } from "../Mob"
import { ellipsoid, nextRand, wanderHeading } from "./mobkit"
// Everything about the frog: squat, ground-bound, sits then springs a ballistic hop.
export const frog: Entity<Mob, Terrain> = {
name: "frog",
build,
export type Frog = ActorDefinition<MobState, Ground>
export namespace Frog {
export function create(material: Material): Frog {
const mesh = Mesh.create()
build(mesh)
return {
prototype: {
groups: [{ mesh, material }],
radius: 0.7,
minY: -0.7,
maxY: 1.3,
},
update,
boundingRadius: 0.7,
bodyHeight: 0.6,
transform: Mob.transform,
collider: (state) => Mob.collider(state, 0.7, 0.6, state.grounded),
}
}
}
const LEASH = 5
@ -21,7 +34,7 @@ const HOP_SPEED = 1.6
const HOP_IMPULSE = 3.2
const GRAVITY = 14
function build(mesh: Mesh): void {
function build(mesh: Geometry): void {
// Wide squat body, two eye bumps on the top-front, two hind haunches. UVs:
// the frog texture is green skin on the left, a dark eye tone on the right.
ellipsoid(mesh, 0, 0.26, 0, 0.5, 0.28, 0.52, 6, 4, 0, 0.68, 0, 1)
@ -31,7 +44,7 @@ function build(mesh: Mesh): void {
ellipsoid(mesh, -0.3, 0.2, -0.26, 0.2, 0.2, 0.26, 4, 3, 0, 0.68, 0, 1)
}
function update(mob: Mob, dt: number, terrain: Terrain): void {
function update(mob: MobState, dt: number, terrain: Ground): void {
if (mob.grounded) {
mob.timer -= dt
mob.position.y = Terrain.height(terrain, mob.position.x, mob.position.z)

View file

@ -1,18 +1,31 @@
import { Terrain } from "../../Terrain"
import type { Mesh } from "../../../engine/scene/Mesh"
import type { Mob } from "../Mob"
import type { Entity } from "../../../engine/scene/Actor"
import { Terrain, type Terrain as Ground } from "../../../engine/world/Terrain"
import { Mesh, type Mesh as Geometry } from "../../../engine/scene/Mesh"
import type { Material } from "../../../engine/render/Material"
import type { ActorDefinition } from "../../../engine/scene/Actor"
import { Mob, type MobState } from "../Mob"
import { ellipsoid, nextRand, wanderHeading } from "./mobkit"
// Everything about the robin: round red-breasted bird that mostly hops like a frog
// but now and then takes a short powered flight to a new perch.
export const robin: Entity<Mob, Terrain> = {
name: "robin",
build,
export type Robin = ActorDefinition<MobState, Ground>
export namespace Robin {
export function create(material: Material): Robin {
const mesh = Mesh.create()
build(mesh)
return {
prototype: {
groups: [{ mesh, material }],
radius: 0.5,
minY: -0.5,
maxY: 1,
},
update,
boundingRadius: 0.45,
bodyHeight: 0.55,
transform: Mob.transform,
collider: (state) => Mob.collider(state, 0.45, 0.55, state.grounded),
}
}
}
const LEASH = 6
@ -27,7 +40,7 @@ const FLY_IMPULSE = 3.5
const CRUISE = 0.8
const GRAVITY = 14
function build(mesh: Mesh): void {
function build(mesh: Geometry): void {
// Round European robin: plump brown body, an orange-red breast bulging on the
// front, a round brown head with two dark eyes + a small dark beak, short tail.
// UVs: robin texture is brown (left), orange breast (mid), dark eye/beak (right).
@ -40,7 +53,7 @@ function build(mesh: Mesh): void {
ellipsoid(mesh, 0, 0.26, -0.32, 0.09, 0.05, 0.16, 4, 2, 0, 0.38, 0, 1) // tail (brown)
}
function update(mob: Mob, dt: number, terrain: Terrain): void {
function update(mob: MobState, dt: number, terrain: Ground): void {
if (mob.grounded) {
mob.timer -= dt
mob.position.y = Terrain.height(terrain, mob.position.x, mob.position.z)

View file

@ -1,10 +1,8 @@
import type { Mob } from "../Mob"
import type { MobState } from "../Mob"
import { STRIDE, type Mesh } from "../../../engine/scene/Mesh"
// Shared building blocks for the per-kind mob definitions (Frog/Bee/Robin): the
// faceted geometry primitives and the deterministic wander helpers. Kept in its own
// module (no runtime import of `Mob`, only its type) so the per-kind files and the
// `Mob` registry don't form an import cycle.
// Shared building blocks for concrete mob definitions: faceted geometry primitives
// and deterministic wander helpers.
export const TAU = Math.PI * 2
@ -13,7 +11,11 @@ export const TAU = Math.PI * 2
/** A new heading: free wander when inside the leash, else biased back toward home
* so the mob never drifts off into the peaks (`jitter` = the random cone half-width
* in radians layered on top of the homeward bearing). */
export function wanderHeading(mob: Mob, leash: number, jitter: number): number {
export function wanderHeading(
mob: MobState,
leash: number,
jitter: number,
): number {
const dx = mob.home.x - mob.position.x
const dz = mob.home.z - mob.position.z
if (dx * dx + dz * dz > leash * leash) {
@ -24,7 +26,7 @@ export function wanderHeading(mob: Mob, leash: number, jitter: number): number {
/** mulberry32 step over the mob's own `seed` (mutated), so a mob's motion is
* deterministic and needs no external RNG object to clone. */
export function nextRand(mob: Mob): number {
export function nextRand(mob: MobState): number {
const a = (mob.seed + 0x6D2B79F5) | 0
mob.seed = a
let t = Math.imul(a ^ (a >>> 15), 1 | a)
@ -33,7 +35,7 @@ export function nextRand(mob: Mob): number {
}
// --- Geometry primitives --------------------------------------------------
// Mobs are drawn double-sided (see renderScene), so winding is not load-bearing --
// Mobs are drawn double-sided, so winding is not load-bearing --
// these only need to place faceted, flat-shaded surfaces.
/** A UV-rected ellipsoid (pole on Y), faceted like the boulders. */
@ -61,7 +63,13 @@ export function ellipsoid(
for (let is = 0; is <= seg; is++) {
const theta = (is / seg) * TAU
const u = u0 + (u1 - u0) * (is / seg)
mesh.verts.push(cx + crv * Math.cos(theta) * rx, cy + cyv * ry, cz + crv * Math.sin(theta) * rz, u, v)
mesh.verts.push(
cx + crv * Math.cos(theta) * rx,
cy + cyv * ry,
cz + crv * Math.sin(theta) * rz,
u,
v,
)
}
}
quadGrid(mesh, start, seg, rings)
@ -97,13 +105,36 @@ export function ovoidZ(
}
/** One flat wing quad on `side` (+1 right / -1 left), swept up and out. */
export function wing(mesh: Mesh, side: number, u0: number, u1: number, v0: number, v1: number): void {
export function wing(
mesh: Mesh,
side: number,
u0: number,
u1: number,
v0: number,
v1: number,
): void {
const base = mesh.verts.length / STRIDE
mesh.verts.push(
side * 0.06, 0.12, 0.14, u0, v0,
side * 0.42, 0.24, 0.1, u1, v0,
side * 0.42, 0.24, -0.12, u1, v1,
side * 0.06, 0.12, -0.1, u0, v1,
side * 0.06,
0.12,
0.14,
u0,
v0,
side * 0.42,
0.24,
0.1,
u1,
v0,
side * 0.42,
0.24,
-0.12,
u1,
v1,
side * 0.06,
0.12,
-0.1,
u0,
v1,
)
mesh.indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
}

View file

@ -1,15 +1,51 @@
import { Vec3 } from "../../../engine/math/Vec3"
import type { Mesh } from "../../../engine/scene/Mesh"
import type { Tree, TreeSpecies } from "../Tree"
import type { Material } from "../../../engine/render/Material"
import type { Prefab } from "../../../engine/scene/Prefab"
import type { Tree } from "../Tree"
import { blob, lerp, limb, TAU, rng } from "./treekit"
// Silver birch: tall, slender, near-straight trunk under an airy, high, slightly
// drooping canopy -- a lean silhouette between the broad oak and conical spruce.
// Trunk = white birch bark, foliage = oak leaf (the white trunk carries the read).
export const birch: TreeSpecies = { kind: "birch", trunk: "birch", foliage: "leaf", build }
export type Birch = Prefab<Tree>
function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void {
export namespace Birch {
export function create(trunk: Material, foliage: Material): Birch {
return {
position: (tree) => tree.position,
bakeNear: (tree, batch) =>
build(tree, batch.mesh(trunk), batch.mesh(foliage), "full"),
bakeFar: (tree, batch) =>
build(tree, batch.mesh(trunk), batch.mesh(foliage), "impostor"),
collider: treeCollider,
}
}
function treeCollider(tree: Tree) {
if (tree.growth <= 0.35) {
return null
}
const radius = tree.growth * 0.2 + 0.15
return {
shape: "box" as const,
minX: tree.position.x - radius,
maxX: tree.position.x + radius,
minZ: tree.position.z - radius,
maxZ: tree.position.z + radius,
top: tree.position.y + 3,
standable: false,
}
}
}
function build(
tree: Tree,
trunk: Mesh,
leaves: Mesh,
lod: "full" | "impostor",
): void {
const base = tree.position
const g = tree.growth
const rand = rng(tree.seed)
@ -18,11 +54,25 @@ function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"):
const canopyY = base.y + h * 0.75
const blobR = h * 0.22
if (lod === "impostor") {
limb(trunk, base, { x: base.x, y: base.y + h * 0.9, z: base.z }, rTrunk, rTrunk * 0.5, 3)
limb(
trunk,
base,
{ x: base.x, y: base.y + h * 0.9, z: base.z },
rTrunk,
rTrunk * 0.5,
3,
)
blob(leaves, { x: base.x, y: canopyY, z: base.z }, blobR * 1.1, rand, 4, 2)
return
}
limb(trunk, base, { x: base.x, y: base.y + h * 0.88, z: base.z }, rTrunk, rTrunk * 0.35, 5)
limb(
trunk,
base,
{ x: base.x, y: base.y + h * 0.88, z: base.z },
rTrunk,
rTrunk * 0.35,
5,
)
const spread = h * 0.22
// Sparse small blobs clustered high, biased downward so the crown droops.
@ -42,7 +92,11 @@ function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"):
const branches = 2 + Math.round(rand())
for (let i = 0; i < branches; i++) {
const angle = rand() * TAU
const dir = Vec3.normalize({ x: Math.cos(angle), y: 0.6, z: Math.sin(angle) })
const dir = Vec3.normalize({
x: Math.cos(angle),
y: 0.6,
z: Math.sin(angle),
})
const start = { x: base.x, y: base.y + h * 0.7, z: base.z }
const end = Vec3.add(start, Vec3.scale(dir, h * 0.22))
limb(trunk, start, end, rTrunk * 0.4, rTrunk * 0.15, 4)

View file

@ -1,14 +1,48 @@
import { Vec3 } from "../../../engine/math/Vec3"
import type { Mesh } from "../../../engine/scene/Mesh"
import type { Tree, TreeSpecies } from "../Tree"
import type { Material } from "../../../engine/render/Material"
import type { Prefab } from "../../../engine/scene/Prefab"
import type { Tree } from "../Tree"
import { blob, lerp, limb, TAU, rng } from "./treekit"
// Oak: short tapered trunk, a couple of branches, a broad cluster of rounded canopy
// blobs (bushy, wider than tall). Trunk = brown bark, foliage = oak leaf.
export const oak: TreeSpecies = { kind: "oak", trunk: "bark", foliage: "leaf", build }
export type Oak = Prefab<Tree>
function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void {
export namespace Oak {
export function create(trunk: Material, foliage: Material): Oak {
return {
position: (tree) => tree.position,
bakeNear: (tree, batch) =>
build(tree, batch.mesh(trunk), batch.mesh(foliage), "full"),
bakeFar: (tree, batch) =>
build(tree, batch.mesh(trunk), batch.mesh(foliage), "impostor"),
collider(tree) {
if (tree.growth <= 0.35) {
return null
}
const radius = tree.growth * 0.3 + 0.15
return {
shape: "box",
minX: tree.position.x - radius,
maxX: tree.position.x + radius,
minZ: tree.position.z - radius,
maxZ: tree.position.z + radius,
top: tree.position.y + 3,
standable: false,
}
},
}
}
}
function build(
tree: Tree,
trunk: Mesh,
leaves: Mesh,
lod: "full" | "impostor",
): void {
const base = tree.position
const g = tree.growth
const rand = rng(tree.seed)
@ -19,7 +53,14 @@ function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"):
const blobR = h * 0.3
if (lod === "impostor") {
// One low-poly blob on a stubby trunk -- reads as an oak at distance.
limb(trunk, base, { x: base.x, y: forkY, z: base.z }, rTrunk, rTrunk * 0.6, 3)
limb(
trunk,
base,
{ x: base.x, y: forkY, z: base.z },
rTrunk,
rTrunk * 0.6,
3,
)
blob(leaves, { x: base.x, y: canopyY, z: base.z }, blobR * 1.15, rand, 4, 2)
return
}
@ -43,7 +84,11 @@ function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"):
const branches = 2 + Math.round(rand())
for (let i = 0; i < branches; i++) {
const angle = rand() * TAU
const dir = Vec3.normalize({ x: Math.cos(angle), y: 1.2, z: Math.sin(angle) })
const dir = Vec3.normalize({
x: Math.cos(angle),
y: 1.2,
z: Math.sin(angle),
})
const start = { x: base.x, y: base.y + h * 0.42, z: base.z }
const end = Vec3.add(start, Vec3.scale(dir, h * 0.3))
limb(trunk, start, end, rTrunk * 0.4, rTrunk * 0.2, 4)

View file

@ -1,20 +1,63 @@
import type { Mesh } from "../../../engine/scene/Mesh"
import type { Tree, TreeSpecies } from "../Tree"
import type { Material } from "../../../engine/render/Material"
import type { Prefab } from "../../../engine/scene/Prefab"
import type { Tree } from "../Tree"
import { cone, lerp, limb, rng } from "./treekit"
// Spruce: tall thin trunk under stacked cones that narrow to a point (tiered, taller
// than wide). Trunk = brown bark, foliage = spruce needle.
export const spruce: TreeSpecies = { kind: "spruce", trunk: "bark", foliage: "needle", build }
export type Spruce = Prefab<Tree>
function build(tree: Tree, trunk: Mesh, needles: Mesh, lod: "full" | "impostor"): void {
export namespace Spruce {
export function create(trunk: Material, foliage: Material): Spruce {
return {
position: (tree) => tree.position,
bakeNear: (tree, batch) =>
build(tree, batch.mesh(trunk), batch.mesh(foliage), "full"),
bakeFar: (tree, batch) =>
build(tree, batch.mesh(trunk), batch.mesh(foliage), "impostor"),
collider: treeCollider,
}
}
function treeCollider(tree: Tree) {
if (tree.growth <= 0.35) {
return null
}
const radius = tree.growth * 0.2 + 0.15
return {
shape: "box" as const,
minX: tree.position.x - radius,
maxX: tree.position.x + radius,
minZ: tree.position.z - radius,
maxZ: tree.position.z + radius,
top: tree.position.y + 3,
standable: false,
}
}
}
function build(
tree: Tree,
trunk: Mesh,
needles: Mesh,
lod: "full" | "impostor",
): void {
const base = tree.position
const g = tree.growth
const rand = rng(tree.seed)
const h = lerp(0.6, 9, g)
const rTrunk = lerp(0.03, 0.2, g)
const impostor = lod === "impostor"
limb(trunk, base, { x: base.x, y: base.y + h, z: base.z }, rTrunk, rTrunk * 0.25, impostor ? 3 : 5)
limb(
trunk,
base,
{ x: base.x, y: base.y + h, z: base.z },
rTrunk,
rTrunk * 0.25,
impostor ? 3 : 5,
)
// Stacked cones: widest low, shrinking to a point up top -> conical tiers. The
// impostor keeps the first two tiers at low sides (same seed => aligned).

View file

@ -1,9 +1,8 @@
import { Vec3 } from "../../../engine/math/Vec3"
import { STRIDE, type Mesh } from "../../../engine/scene/Mesh"
// Shared faceted-geometry primitives + the per-tree RNG, used by the species
// modules (Oak/Spruce/Birch). Kept separate so a species and the `Tree` registry
// don't form an import cycle.
// Shared faceted-geometry primitives + the per-tree RNG used by concrete tree
// prefab modules.
export const TAU = Math.PI * 2

View file

@ -1,51 +1,37 @@
import { Color } from "../engine/render/Color"
import type { DrawGroup, Material } from "../engine/render/Material"
import type { Material } from "../engine/render/Material"
import type { CloudLayer, SkyConfig } from "../engine/render/Sky"
import { STRIDE, type Mesh } from "../engine/scene/Mesh"
import { ChunkBuilder } from "../engine/render/ChunkBuilder"
import { Actor, type ActorDefinition } from "../engine/scene/Actor"
import { Mesh } from "../engine/scene/Mesh"
import { MeshBuilder } from "../engine/scene/MeshBuilder"
import {
Prefab,
type PlacedPrefab,
type Prefab as PrefabDefinition,
} from "../engine/scene/Prefab"
import type { BoxCollider, Collider } from "../engine/world/Collider"
import { Level, type Level as RuntimeLevel } from "../engine/world/Level"
import {
Terrain,
type RollingTerrainConfig,
type Terrain as Ground,
} from "../engine/world/Terrain"
import { Boulder } from "./actors/Boulder"
import { Bush } from "./actors/Bush"
import { Flower, type FlowerColor } from "./actors/Flower"
import type { Mob, MobKind } from "./actors/Mob"
import { Terrain } from "./Terrain"
import { Tree } from "./actors/Tree"
import { Flower, type FlowerStyle } from "./actors/Flower"
import type { Mob, MobState } from "./actors/Mob"
import type { Tree } from "./actors/Tree"
import { Bee } from "./actors/mobs/Bee"
import { Frog } from "./actors/mobs/Frog"
import { Robin } from "./actors/mobs/Robin"
import { Birch } from "./actors/trees/Birch"
import { Oak } from "./actors/trees/Oak"
import { Spruce } from "./actors/trees/Spruce"
import type { Textures } from "./textures"
type Corner = [number, number, number]
/** Axis-aligned solid. Blocks the player horizontally while their feet are
* below `top`; if `standable`, its `top` also counts as ground to land on. */
export type Aabb = {
minX: number
maxX: number
minZ: number
maxZ: number
top: number
standable: boolean
}
/** One spatial cell of the outdoor world: its terrain patch + the trees/boulders
* standing in it, baked into `DrawGroup`s (mesh + material), plus an axis-aligned
* bounding box (tight to the actual geometry, so overhanging canopies aren't
* clipped). The renderer frustum-tests the box and skips the whole cell when it
* is off-screen -- this is what keeps a big, dense world affordable. Empty cells
* are never created; empty groups are pruned at bake time. */
export type Chunk = {
minX: number
minY: number
minZ: number
maxX: number
maxY: number
maxZ: number
/** Full-detail draw groups (grass + full trees/boulders), used up close. */
near: DrawGroup[]
/** LOD draw groups (grass + cheap tree/boulder impostors, no bushes/flowers),
* used once the chunk is past `config.lodDistance` (see `chunkFar`). */
far: DrawGroup[]
}
/** The materials the chunk baker binds its meshes to -- one per ground/prop
* texture. Built once from the loaded `Textures`, shared across every chunk. */
type ChunkMaterials = {
type Materials = {
floor: Material
grass: Material
bark: Material
birch: Material
@ -53,50 +39,35 @@ type ChunkMaterials = {
needle: Material
rock: Material
flower: Material
wall: Material
crate: Material
npc: Material
frog: Material
bee: Material
robin: Material
}
/** A chunk-material key (also the tag props reference, e.g. a tree's `trunk`). */
type MatKey = keyof ChunkMaterials
type WeightedTree = {
definition: PrefabDefinition<Tree>
weight: number
}
/** The fixed order draw groups are emitted in (grass first, flowers -- double-sided
* -- last), so the per-chunk draw sequence is deterministic and matches the pre-
* registry order. Every material key must appear here. */
const MAT_ORDER: MatKey[] = ["grass", "rock", "bark", "birch", "leaf", "needle", "flower"]
/** The playground: a flat-floored room dropped into the center of a big open
* landscape. The room (floor/walls/crate) is small and always drawn; the
* outdoor world is split into `chunks` that are frustum-culled per frame. */
export type Level = {
floor: Mesh
walls: Mesh
crate: Mesh
chunks: Chunk[]
colliders: Aabb[]
npcPosition: { x: number; y: number; z: number }
/** Roaming mobs -- simulated on the main thread each frame (see main.ts), not
* baked into the static culled chunks. */
mobs: Mob[]
terrain: Terrain
sky: SkyConfig
type MobSpawn = {
definition: ActorDefinition<MobState, Ground>
count: number
minScale: number
maxScale: number
grounded: boolean
phase: (random: () => number) => number
}
const ARENA = 12
const WALL_HEIGHT = 4
/** How deep the perimeter walls are. Thick enough to read as solid walls (and to
* give the doorway real jambs); their outer faces sit flush with the room edge,
* so they eat into the interior, not the terrain. */
const WALL_THICKNESS = 1.5
const CRATE = { x: -2, z: -2, half: 1, height: 1 }
/** Z-bias lifting the stone floor above the terrain skirt that laps under the
* room edge (see `buildLevel`). Big enough to beat depth precision, too small
* to see. */
const FLOOR_LIFT = 0.02
/** The world around the room: a flat clearing the size of the room (`inner`),
* rolling hills beyond, ramping into very high peaks at the `outer` rim ~20x
* the room across. Tune freely -- crank `peakHeight` for taller mountains,
* `outer` for a bigger world. */
const TERRAIN: Terrain = {
const TERRAIN_CONFIG: RollingTerrainConfig = {
inner: ARENA,
outer: ARENA * 10,
blend: 12,
@ -106,52 +77,30 @@ const TERRAIN: Terrain = {
peakFrequency: 0.05,
peakStart: 0.45,
}
const TERRAIN = Terrain.rolling(TERRAIN_CONFIG)
/** Forest: how many trees to scatter on the grass, and the seed for their
* placement/kind/growth. Trees ring the room out to `TREE_REACH` of the world;
* each rolls oak-or-spruce and a growth 0..1 (sapling .. full grown). */
const TREE_COUNT = 50
const TREE_SEED = 0x5EED
const TREE_REACH = 1
/** Boulders: how many to scatter, their seed, and how far out they reach
* (fraction of the world). Sizes range small pebble .. big boulder. */
const BOULDER_COUNT = 50
const BOULDER_SEED = 0xB0142
const BOULDER_REACH = 1
/** Bushes + flowers: ground detail, kept to the nearer band since they're small
* and fog/size hides them far out. Flowers roll white/red/yellow. */
const BUSH_COUNT = 50
const BUSH_SEED = 0xB554
const BUSH_REACH = 1
const FLOWER_COUNT = 50
const FLOWER_SEED = 0xF10E
const FLOWER_REACH = 0.3
const FLOWER_COLORS: FlowerColor[] = ["white", "red", "yellow"]
/** Roaming mobs: how many frogs/bees to scatter, their seed, and how far out they
* reach (fraction of the world). Kept modest -- roaming meshes are drawn every
* frame (frustum-culled), not baked into the static chunks. */
const FLOWER_STYLES: FlowerStyle[] = [Flower.white, Flower.red, Flower.yellow]
const FROG_COUNT = 20
const BEE_COUNT = 20
const ROBIN_COUNT = 20
const MOB_SEED = 0x30B
const MOB_REACH = 1
/** Spatial partition of the world for frustum culling: `CHUNK_GRID` x
* `CHUNK_GRID` square cells over [-outer, outer]. Smaller cells cull tighter
* (less drawn off-screen) but cost more per-cell tests + bounds; this is the
* granularity knob. `TERRAIN_SUBDIV` is the terrain quads per cell edge, so the
* world's terrain resolution is `CHUNK_GRID * TERRAIN_SUBDIV`. `GROUND_UV` sets
* texture tiles/unit. */
const CHUNK_GRID = 12
const TERRAIN_SUBDIV = 5
const GROUND_UV = 0.25
/** The two cloud styles; swap which one the sky uses in `buildLevel`.
* `basicCumulus` is cheap flat puffs; `fancyCumulus` is the pricier
* heightfield-shaded, domain-warped version with faked volume. */
export const basicCumulus: CloudLayer = {
kind: "basic",
color: Color.rgb(248, 250, 255),
@ -172,33 +121,63 @@ export const fancyCumulus: CloudLayer = {
relief: 7,
}
export function buildLevel(textures: Textures): Level {
// Flat room floor, lifted a hair above the terrain's clearing (y 0). The
// outdoor grid's cells straddle the room boundary and lap under the floor's
// edges; this small z-bias keeps the flat stone floor winning the depth test
// there instead of z-fighting the grass. The step is invisible at the doorway.
const floor = mesh()
const fy = FLOOR_LIFT
quad(floor, [-ARENA, fy, -ARENA], [ARENA, fy, -ARENA], [ARENA, fy, ARENA], [-ARENA, fy, ARENA], 12, 12)
/** Concrete playground data assembled through engine-owned level mechanisms. */
export function buildLevel(textures: Textures): RuntimeLevel<Ground> {
const materials = createMaterials(textures)
const floor = Mesh.create()
MeshBuilder.quad(
floor,
[-ARENA, FLOOR_LIFT, -ARENA],
[ARENA, FLOOR_LIFT, -ARENA],
[ARENA, FLOOR_LIFT, ARENA],
[-ARENA, FLOOR_LIFT, ARENA],
12,
12,
)
const walls = mesh()
const h = WALL_HEIGHT
const t = WALL_THICKNESS
// Three thick perimeter walls, outer faces flush with the room edge; the north
// (-Z) side is left open onto the world. No ceiling, so the sky shows above.
slab(walls, -ARENA, ARENA, ARENA - t, ARENA, 0, h, 0.5) // south (+Z)
slab(walls, ARENA - t, ARENA, -ARENA, ARENA - t, 0, h, 0.5) // east (+X)
slab(walls, -ARENA, -ARENA + t, -ARENA, ARENA - t, 0, h, 0.5) // west (-X)
const walls = Mesh.create()
const thickness = WALL_THICKNESS
MeshBuilder.slab(
walls,
-ARENA,
ARENA,
ARENA - thickness,
ARENA,
0,
WALL_HEIGHT,
0.5,
)
MeshBuilder.slab(
walls,
ARENA - thickness,
ARENA,
-ARENA,
ARENA - thickness,
0,
WALL_HEIGHT,
0.5,
)
MeshBuilder.slab(
walls,
-ARENA,
-ARENA + thickness,
-ARENA,
ARENA - thickness,
0,
WALL_HEIGHT,
0.5,
)
// Crate on the flat room floor.
const crate = mesh()
box(crate, CRATE.x, CRATE.z, CRATE.half, 0, CRATE.height)
const crate = Mesh.create()
MeshBuilder.box(crate, CRATE.x, CRATE.z, CRATE.half, 0, CRATE.height)
const colliders: Aabb[] = [
wall(-ARENA, ARENA, ARENA - t, ARENA),
wall(ARENA - t, ARENA, -ARENA, ARENA - t),
wall(-ARENA, -ARENA + t, -ARENA, ARENA - t),
const npcPosition = { x: 2, y: 0, z: -1 }
const staticColliders: Collider[] = [
wall(-ARENA, ARENA, ARENA - thickness, ARENA),
wall(ARENA - thickness, ARENA, -ARENA, ARENA - thickness),
wall(-ARENA, -ARENA + thickness, -ARENA, ARENA - thickness),
{
shape: "box",
minX: CRATE.x - CRATE.half,
maxX: CRATE.x + CRATE.half,
minZ: CRATE.z - CRATE.half,
@ -206,8 +185,94 @@ export function buildLevel(textures: Textures): Level {
top: CRATE.height,
standable: true,
},
{
shape: "circle",
x: npcPosition.x,
z: npcPosition.z,
radius: 0.5,
top: Infinity,
standable: false,
},
]
const treeDefinitions: WeightedTree[] = [
{ definition: Oak.create(materials.bark, materials.leaf), weight: 0.4 },
{
definition: Spruce.create(materials.bark, materials.needle),
weight: 0.32,
},
{ definition: Birch.create(materials.birch, materials.leaf), weight: 0.28 },
]
const props = [
...placeTrees(treeDefinitions),
...placeBoulders(Boulder.create(materials.rock)),
...placeBushes(Bush.create(materials.leaf)),
...placeFlowers(Flower.create(materials.flower)),
]
for (const prop of props) {
if (prop.collider !== null) {
staticColliders.push(prop.collider)
}
}
const chunks = ChunkBuilder.build(
{
minX: TERRAIN.minX,
minZ: TERRAIN.minZ,
maxX: TERRAIN.maxX,
maxZ: TERRAIN.maxZ,
columns: CHUNK_GRID,
rows: CHUNK_GRID,
bakeCell(near, far, cell) {
const ground = near.mesh(materials.grass)
far.use(materials.grass, ground)
Terrain.patch(
TERRAIN,
ground,
cell.x0,
cell.z0,
cell.x1,
cell.z1,
TERRAIN_SUBDIV,
TERRAIN_SUBDIV,
GROUND_UV,
(x, z) => Math.max(Math.abs(x), Math.abs(z)) >= ARENA,
)
},
},
props,
)
const frog = Frog.create(materials.frog)
const bee = Bee.create(materials.bee)
const robin = Robin.create(materials.robin)
const actors = placeMobs([
{
definition: frog,
count: FROG_COUNT,
minScale: 0.5,
maxScale: 0.85,
grounded: true,
phase: () => 0,
},
{
definition: bee,
count: BEE_COUNT,
minScale: 0.5,
maxScale: 0.8,
grounded: false,
phase: (random) => random() * 10,
},
{
definition: robin,
count: ROBIN_COUNT,
minScale: 0.4,
maxScale: 0.65,
grounded: true,
phase: () => 0,
},
])
const sky: SkyConfig = {
zenith: Color.rgb(58, 108, 196),
horizon: Color.rgb(178, 198, 226),
@ -218,12 +283,31 @@ export function buildLevel(textures: Textures): Level {
skybox: { texture: textures.skybox },
}
const npcPosition = { x: 2, y: 0, z: -1 }
return Level.create({
terrain: TERRAIN,
actorWorld: TERRAIN,
actors,
staticColliders,
staticGroups: [
{ mesh: floor, material: materials.floor },
{ mesh: walls, material: materials.wall },
{ mesh: crate, material: materials.crate },
],
chunks,
billboards: [
{
position: npcPosition,
size: { x: 1.1, y: 1.5 },
material: materials.npc,
},
],
sky,
})
}
// The ground/prop materials the chunk baker draws with (grass + trees + rocks +
// flowers). Solid surfaces backface-cull; flowers are double-sided. Shared by
// every chunk, so cloning to a worker dedups them.
const materials: ChunkMaterials = {
function createMaterials(textures: Textures): Materials {
return {
floor: { texture: textures.floor, cull: false },
grass: { texture: textures.grass, cull: true },
bark: { texture: textures.bark, cull: true },
birch: { texture: textures.birch, cull: true },
@ -231,319 +315,219 @@ export function buildLevel(textures: Textures): Level {
needle: { texture: textures.needle, cull: true },
rock: { texture: textures.rock, cull: true },
flower: { texture: textures.flower, cull: false },
wall: { texture: textures.wall, cull: false },
crate: { texture: textures.crate, cull: false },
npc: { texture: textures.npc, cull: false },
frog: { texture: textures.frog, cull: false },
bee: { texture: textures.bee, cull: false },
robin: { texture: textures.robin, cull: false },
}
// Place the props (also pushes their colliders), then bake everything into
// frustum-cullable spatial chunks.
const trees = placeTrees(colliders)
const boulders = placeBoulders(colliders)
const bushes = placeBushes()
const flowers = placeFlowers()
const chunks = buildChunks(materials, trees, boulders, bushes, flowers)
const mobs = placeMobs()
return { floor, walls, crate, chunks, colliders, npcPosition, mobs, terrain: TERRAIN, sky }
}
/** Bake the terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` set of spatial
* chunks. Each prop lands in the cell holding its base; the cell's bounds are
* grown to the real geometry so overhanging canopies never get culled early.
* Bushes share the oak leaf mesh; flowers get their own (double-sided) mesh. */
function buildChunks(m: ChunkMaterials, trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers: Flower[]): Chunk[] {
const cell = (TERRAIN.outer * 2) / CHUNK_GRID
const chunks: Chunk[] = []
for (let ci = 0; ci < CHUNK_GRID; ci++) {
const x0 = -TERRAIN.outer + ci * cell
const x1 = x0 + cell
for (let cj = 0; cj < CHUNK_GRID; cj++) {
const z0 = -TERRAIN.outer + cj * cell
const z1 = z0 + cell
// Accumulate geometry into one mesh per material key, for the near (full) and
// far (impostor) LOD sets. Props declare which material(s) they write, so the
// baker never names a texture -- adding a species/material touches no code here.
const near = new Map<string, Mesh>()
const far = new Map<string, Mesh>()
const grass = matMesh(near, "grass")
far.set("grass", grass) // the ground is drawn in both LOD sets
Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV)
for (const tree of trees) {
if (inCell(tree.position, x0, z0, x1, z1)) {
const s = Tree.species(tree.kind)
Tree.build(tree, matMesh(near, s.trunk), matMesh(near, s.foliage))
Tree.build(tree, matMesh(far, s.trunk), matMesh(far, s.foliage), "impostor")
}
}
for (const boulder of boulders) {
if (inCell(boulder.position, x0, z0, x1, z1)) {
Boulder.build(boulder, matMesh(near, "rock"))
Boulder.build(boulder, matMesh(far, "rock"), "impostor")
}
}
// Bushes fold into the near leaf mesh; they just drop out past lodDistance.
for (const bush of bushes) {
if (inCell(bush.position, x0, z0, x1, z1)) {
Bush.build(bush, matMesh(near, "leaf"))
}
}
for (const flower of flowers) {
if (inCell(flower.position, x0, z0, x1, z1)) {
Flower.build(flower, matMesh(near, "flower"))
}
}
const b = bounds([...near.values()])
if (b === null) {
function placeTrees(definitions: WeightedTree[]): PlacedPrefab[] {
const random = mulberry(TREE_SEED)
const maxDistance = TERRAIN_CONFIG.outer * TREE_REACH
const trees: PlacedPrefab[] = []
for (
let guard = 0;
trees.length < TREE_COUNT && guard < TREE_COUNT * 20;
guard++
) {
const point = scatterPoint(random, maxDistance, 5, 3)
if (point === null) {
continue
}
chunks.push({ ...b, near: toGroups(near, m), far: toGroups(far, m) })
}
}
return chunks
}
function inCell(p: { x: number; z: number }, x0: number, z0: number, x1: number, z1: number): boolean {
return p.x >= x0 && p.x < x1 && p.z >= z0 && p.z < z1
}
/** Lazily get (creating on first use) the accumulation mesh for a material key in a
* chunk's near/far map. Props write into these by key, so the baker stays generic. */
function matMesh(map: Map<string, Mesh>, key: string): Mesh {
let m = map.get(key)
if (m === undefined) {
m = mesh()
map.set(key, m)
}
return m
}
/** Turn a chunk's per-material meshes into a draw-group list, in a fixed material
* order (so the draw sequence is deterministic across bakes) and dropping any that
* ended up empty (a cell rarely holds every prop kind). */
function toGroups(map: Map<string, Mesh>, materials: ChunkMaterials): DrawGroup[] {
const out: DrawGroup[] = []
for (const key of MAT_ORDER) {
const m = map.get(key)
if (m !== undefined && m.indices.length > 0) {
out.push({ mesh: m, material: materials[key] })
}
}
return out
}
/** Tight AABB over several meshes' vertices, or null if they are all empty. */
function bounds(meshes: Mesh[]): Pick<Chunk, "minX" | "minY" | "minZ" | "maxX" | "maxY" | "maxZ"> | null {
let minX = Infinity
let minY = Infinity
let minZ = Infinity
let maxX = -Infinity
let maxY = -Infinity
let maxZ = -Infinity
for (const m of meshes) {
const verts = m.verts
for (let i = 0; i < verts.length; i += STRIDE) {
const x = verts[i]
const y = verts[i + 1]
const z = verts[i + 2]
minX = Math.min(minX, x)
minY = Math.min(minY, y)
minZ = Math.min(minZ, z)
maxX = Math.max(maxX, x)
maxY = Math.max(maxY, y)
maxZ = Math.max(maxZ, z)
}
}
return maxX < minX ? null : { minX, minY, minZ, maxX, maxY, maxZ }
}
/** Place `TREE_COUNT` trees around the room on walkable grass: each sits on the
* terrain, rolls oak/spruce and a growth stage, and (once past sapling size)
* drops a trunk collider so you can't walk through it. */
function placeTrees(colliders: Aabb[]): Tree[] {
const rand = mulberry(TREE_SEED)
const maxDist = TERRAIN.outer * TREE_REACH
const trees: Tree[] = []
for (let guard = 0; trees.length < TREE_COUNT && guard < TREE_COUNT * 20; guard++) {
const angle = rand() * Math.PI * 2
const dist = ARENA + 5 + rand() * (maxDist - ARENA - 5)
const x = Math.cos(angle) * dist
const z = Math.sin(angle) * dist
// Stay out of the room clearing and its flat rim.
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 3) {
continue
}
const roll = rand()
const kind = roll < 0.4 ? "oak" : roll < 0.72 ? "spruce" : "birch"
const growth = 0.08 + rand() * 0.92
const position = { x, y: Terrain.height(TERRAIN, x, z), z }
trees.push({ kind, position, growth, seed: (rand() * 0xFFFFFFFF) | 0 })
// Saplings are passable; grown trunks block. Square footprint, non-standable.
if (growth > 0.35) {
const r = growth * (kind === "oak" ? 0.3 : 0.2) + 0.15
colliders.push({ minX: x - r, maxX: x + r, minZ: z - r, maxZ: z + r, top: position.y + 3, standable: false })
const definition = weightedTree(definitions, random())
const tree: Tree = {
position: {
x: point.x,
y: TERRAIN.heightAt(point.x, point.z),
z: point.z,
},
growth: 0.08 + random() * 0.92,
seed: (random() * 0xFFFFFFFF) | 0,
}
trees.push(Prefab.place(definition, tree))
}
return trees
}
/** Scatter `BOULDER_COUNT` boulders across the terrain, sizes biased toward
* small. Each sits on the ground; big ones drop a blocking collider so you
* can't walk through them (little rocks stay passable). */
function placeBoulders(colliders: Aabb[]): Boulder[] {
const rand = mulberry(BOULDER_SEED)
const maxDist = TERRAIN.outer * BOULDER_REACH
const boulders: Boulder[] = []
for (let guard = 0; boulders.length < BOULDER_COUNT && guard < BOULDER_COUNT * 20; guard++) {
const angle = rand() * Math.PI * 2
const dist = ARENA + 4 + rand() * (maxDist - ARENA - 4)
const x = Math.cos(angle) * dist
const z = Math.sin(angle) * dist
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 2) {
function placeBoulders(definition: PrefabDefinition<Boulder>): PlacedPrefab[] {
const random = mulberry(BOULDER_SEED)
const maxDistance = TERRAIN_CONFIG.outer * BOULDER_REACH
const boulders: PlacedPrefab[] = []
for (
let guard = 0;
boulders.length < BOULDER_COUNT && guard < BOULDER_COUNT * 20;
guard++
) {
const point = scatterPoint(random, maxDistance, 4, 2)
if (point === null) {
continue
}
// Square the roll so most rocks are small, a few are big.
const radius = 0.35 + rand() * rand() * 2.2
const position = { x, y: Terrain.height(TERRAIN, x, z), z }
boulders.push({ position, radius, seed: (rand() * 0xFFFFFFFF) | 0 })
if (radius > 0.7) {
colliders.push({ minX: x - radius, maxX: x + radius, minZ: z - radius, maxZ: z + radius, top: position.y + radius * 0.7, standable: false })
}
boulders.push(
Prefab.place(definition, {
position: {
x: point.x,
y: TERRAIN.heightAt(point.x, point.z),
z: point.z,
},
radius: 0.35 + random() * random() * 2.2,
seed: (random() * 0xFFFFFFFF) | 0,
}),
)
}
return boulders
}
/** Scatter bushes on the grass near the play area (no colliders -- walk through). */
function placeBushes(): Bush[] {
const rand = mulberry(BUSH_SEED)
const maxDist = TERRAIN.outer * BUSH_REACH
const bushes: Bush[] = []
for (let guard = 0; bushes.length < BUSH_COUNT && guard < BUSH_COUNT * 20; guard++) {
const angle = rand() * Math.PI * 2
const dist = ARENA + 3 + rand() * (maxDist - ARENA - 3)
const x = Math.cos(angle) * dist
const z = Math.sin(angle) * dist
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 2) {
function placeBushes(definition: PrefabDefinition<Bush>): PlacedPrefab[] {
const random = mulberry(BUSH_SEED)
const maxDistance = TERRAIN_CONFIG.outer * BUSH_REACH
const bushes: PlacedPrefab[] = []
for (
let guard = 0;
bushes.length < BUSH_COUNT && guard < BUSH_COUNT * 20;
guard++
) {
const point = scatterPoint(random, maxDistance, 3, 2)
if (point === null) {
continue
}
bushes.push({ position: { x, y: Terrain.height(TERRAIN, x, z), z }, size: 0.8 + rand() * 1, seed: (rand() * 0xFFFFFFFF) | 0 })
bushes.push(
Prefab.place(definition, {
position: {
x: point.x,
y: TERRAIN.heightAt(point.x, point.z),
z: point.z,
},
size: 0.8 + random(),
seed: (random() * 0xFFFFFFFF) | 0,
}),
)
}
return bushes
}
/** Scatter small flowers on the grass near the play area, colors rolled. */
function placeFlowers(): Flower[] {
const rand = mulberry(FLOWER_SEED)
const maxDist = TERRAIN.outer * FLOWER_REACH
const flowers: Flower[] = []
for (let guard = 0; flowers.length < FLOWER_COUNT && guard < FLOWER_COUNT * 20; guard++) {
const angle = rand() * Math.PI * 2
const dist = ARENA + 2 + rand() * (maxDist - ARENA - 2)
const x = Math.cos(angle) * dist
const z = Math.sin(angle) * dist
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 1) {
function placeFlowers(definition: PrefabDefinition<Flower>): PlacedPrefab[] {
const random = mulberry(FLOWER_SEED)
const maxDistance = TERRAIN_CONFIG.outer * FLOWER_REACH
const flowers: PlacedPrefab[] = []
for (
let guard = 0;
flowers.length < FLOWER_COUNT && guard < FLOWER_COUNT * 20;
guard++
) {
const point = scatterPoint(random, maxDistance, 2, 1)
if (point === null) {
continue
}
const color = FLOWER_COLORS[(rand() * FLOWER_COLORS.length) | 0]
flowers.push({ position: { x, y: Terrain.height(TERRAIN, x, z), z }, color, size: 0.28 + rand() * 0.22, seed: (rand() * 0xFFFFFFFF) | 0 })
flowers.push(
Prefab.place(definition, {
position: {
x: point.x,
y: TERRAIN.heightAt(point.x, point.z),
z: point.z,
},
style: FLOWER_STYLES[(random() * FLOWER_STYLES.length) | 0],
size: 0.28 + random() * 0.22,
seed: (random() * 0xFFFFFFFF) | 0,
}),
)
}
return flowers
}
/** Scatter frogs, bees + robins across the grass (like the boulders), each at its
* home anchor with a random heading and size. No colliders here -- mobs move, so
* their block/stand-on AABBs are rebuilt per frame in `main`. */
function placeMobs(): Mob[] {
const rand = mulberry(MOB_SEED)
const maxDist = TERRAIN.outer * MOB_REACH
function placeMobs(spawns: MobSpawn[]): Mob[] {
const random = mulberry(MOB_SEED)
const maxDistance = TERRAIN_CONFIG.outer * MOB_REACH
const mobs: Mob[] = []
const total = FROG_COUNT + BEE_COUNT + ROBIN_COUNT
for (let guard = 0; mobs.length < total && guard < total * 20; guard++) {
const angle = rand() * Math.PI * 2
const dist = ARENA + 3 + rand() * (maxDist - ARENA - 3)
const x = Math.cos(angle) * dist
const z = Math.sin(angle) * dist
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 2) {
for (const spawn of spawns) {
let placed = 0
for (
let guard = 0;
placed < spawn.count && guard < spawn.count * 20;
guard++
) {
const point = scatterPoint(random, maxDistance, 3, 2)
if (point === null) {
continue
}
const n = mobs.length
const kind: MobKind = n < FROG_COUNT ? "frog" : n < FROG_COUNT + BEE_COUNT ? "bee" : "robin"
const y = Terrain.height(TERRAIN, x, z)
const scale = kind === "frog" ? 0.5 + rand() * 0.35 : kind === "robin" ? 0.4 + rand() * 0.25 : 0.5 + rand() * 0.3
mobs.push({
kind,
home: { x, y, z },
position: { x, y, z },
heading: rand() * Math.PI * 2,
const y = TERRAIN.heightAt(point.x, point.z)
const scale = spawn.minScale + random() * (spawn.maxScale - spawn.minScale)
const heading = random() * Math.PI * 2
const state: MobState = {
home: { x: point.x, y, z: point.z },
position: { x: point.x, y, z: point.z },
heading,
scale,
seed: (rand() * 0xFFFFFFFF) | 0,
seed: (random() * 0xFFFFFFFF) | 0,
vx: 0,
vz: 0,
vy: 0,
timer: rand() * 1.5,
// Bees hover (never grounded) and use phase for the bob; frogs/robins start
// resting on the ground.
phase: kind === "bee" ? rand() * 10 : 0,
grounded: kind !== "bee",
})
timer: random() * 1.5,
phase: spawn.phase(random),
grounded: spawn.grounded,
}
mobs.push(Actor.create(spawn.definition, state))
placed++
}
}
return mobs
}
/** Deterministic 0..1 generator (mulberry32) for tree placement. */
function mulberry(seed: number): () => number {
let a = seed >>> 0
return () => {
a = (a + 0x6D2B79F5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
function scatterPoint(
random: () => number,
maxDistance: number,
clearance: number,
flatMargin: number,
): { x: number; z: number } | null {
const angle = random() * Math.PI * 2
const distance =
ARENA + clearance + random() * (maxDistance - ARENA - clearance)
const x = Math.cos(angle) * distance
const z = Math.sin(angle) * distance
return Math.max(Math.abs(x), Math.abs(z)) < TERRAIN_CONFIG.inner + flatMargin
? null
: { x, z }
}
function weightedTree(
definitions: WeightedTree[],
roll: number,
): PrefabDefinition<Tree> {
let cumulative = 0
for (const entry of definitions) {
cumulative += entry.weight
if (roll < cumulative) {
return entry.definition
}
}
return definitions[definitions.length - 1].definition
}
function wall(
minX: number,
maxX: number,
minZ: number,
maxZ: number,
): BoxCollider {
return {
shape: "box",
minX,
maxX,
minZ,
maxZ,
top: WALL_HEIGHT,
standable: true,
}
}
function mesh(): Mesh {
return { verts: [], indices: [] }
}
/** A perimeter wall collider: blocks from the sides, and `standable` so you can
* jump up and land on its top (given enough JUMP_SPEED to clear WALL_HEIGHT). */
function wall(minX: number, maxX: number, minZ: number, maxZ: number): Aabb {
return { minX, maxX, minZ, maxZ, top: WALL_HEIGHT, standable: true }
}
/** One flat quad (two tris). Corners run a (uv 0,0) -> b (us,0) -> c (us,vs) ->
* d (0,vs); `us`/`vs` set how many texture tiles span it. No subdivision is
* needed -- texturing is perspective-correct, so a single quad looks right at
* any size. */
function quad(m: Mesh, a: Corner, b: Corner, c: Corner, d: Corner, us: number, vs: number): void {
const base = m.verts.length / STRIDE
m.verts.push(a[0], a[1], a[2], 0, 0, b[0], b[1], b[2], us, 0, c[0], c[1], c[2], us, vs, d[0], d[1], d[2], 0, vs)
m.indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
}
/** An axis-aligned box from (x0,z0)-(x1,z1), y0..y1: four sides + top, no bottom
* (never seen from below). `tpu` = texture tiles per world unit, so every face
* tiles at the same density whatever its size. Used for the thick walls. */
function slab(m: Mesh, x0: number, x1: number, z0: number, z1: number, y0: number, y1: number, tpu: number): void {
const dx = (x1 - x0) * tpu
const dz = (z1 - z0) * tpu
const dy = (y1 - y0) * tpu
quad(m, [x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1], dx, dz)
quad(m, [x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], dx, dy)
quad(m, [x1, y0, z1], [x0, y0, z1], [x0, y1, z1], [x1, y1, z1], dx, dy)
quad(m, [x0, y0, z1], [x0, y0, z0], [x0, y1, z0], [x0, y1, z1], dz, dy)
quad(m, [x1, y0, z0], [x1, y0, z1], [x1, y1, z1], [x1, y1, z0], dz, dy)
}
/** A box centered at (cx, cz), rising `height` units from `base`: top face plus
* four sides, one uv tile per face. No bottom (never seen). */
function box(m: Mesh, cx: number, cz: number, half: number, base: number, height: number): void {
const x0 = cx - half
const x1 = cx + half
const z0 = cz - half
const z1 = cz + half
const y0 = base
const y1 = base + height
quad(m, [x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1], 1, 1)
quad(m, [x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], 1, 1)
quad(m, [x1, y0, z1], [x0, y0, z1], [x0, y1, z1], [x1, y1, z1], 1, 1)
quad(m, [x1, y0, z0], [x1, y0, z1], [x1, y1, z1], [x1, y1, z0], 1, 1)
quad(m, [x0, y0, z1], [x0, y0, z0], [x0, y1, z0], [x0, y1, z1], 1, 1)
function mulberry(seed: number): () => number {
let state = seed >>> 0
return () => {
state = (state + 0x6D2B79F5) | 0
let value = Math.imul(state ^ (state >>> 15), 1 | state)
value ^= value + Math.imul(value ^ (value >>> 7), 61 | value)
return ((value ^ (value >>> 14)) >>> 0) / 4294967296
}
}

View file

@ -1,169 +1,32 @@
import { Terrain } from "./Terrain"
import type { Vec3 } from "../engine/math/Vec3"
import type { Aabb, Level } from "./level"
import type {
Character,
CharacterConfig,
} from "../engine/world/CharacterController"
/** The player as a vertical cylinder. `position` is at the feet; the camera
* eye sits EYE_HEIGHT above it. */
export type Player = {
position: Vec3
yaw: number
export type Player = Character & {
pitch: number
velocityY: number
onGround: boolean
}
export const EYE_HEIGHT = 1.6
const RADIUS = 0.35
const SPEED = 6
/** Speed multiplier while a Run key (Shift) is held. Tweak to taste; set high to
* blast across the big terrain -- move+collision is substepped, so walls stay
* solid even at big multipliers. */
const RUN_MULTIPLIER = 2
const GRAVITY = 22
const JUMP_SPEED = 14
const NPC_RADIUS = 0.5
/** Concrete player tuning. Movement and collision behavior live in engine. */
export namespace Player {
/** Advance the player one frame: jump, horizontal move + collision, gravity. */
export function update(player: Player, keys: Set<string>, dt: number, level: Level): void {
if (keys.has("Space") && player.onGround) {
player.velocityY = JUMP_SPEED
player.onGround = false
}
// Move + collide in small substeps: collision is discrete (move, then push
// out), so a single big running step could otherwise skip clean through a
// wall. Substepping keeps each advance short enough to always hit it.
const steps = moveSubsteps(keys, dt)
for (let i = 0; i < steps; i++) {
moveHorizontal(player, keys, dt / steps)
collide(player, level)
}
fall(player, dt, level)
export const actorCollisionRange = 3
export const config: CharacterConfig = {
radius: 0.35,
speed: 6,
runMultiplier: 2,
gravity: 22,
jumpSpeed: 14,
eyeHeight: 1.6,
}
/** Run-speed factor for the frame: RUN_MULTIPLIER while Shift is held, else 1. */
function runFactor(keys: Set<string>): number {
return keys.has("ShiftLeft") || keys.has("ShiftRight") ? RUN_MULTIPLIER : 1
export function create(): Player {
return {
position: { x: 0, y: 0, z: 8 },
yaw: 0,
pitch: 0,
velocityY: 0,
onGround: true,
}
/** Number of move+collide substeps so each advances at most ~RADIUS, keeping
* the player from tunneling walls however fast they run. */
function moveSubsteps(keys: Set<string>, dt: number): number {
const perFrame = SPEED * runFactor(keys) * dt * Math.SQRT2
return Math.max(1, Math.ceil(perFrame / RADIUS))
}
function moveHorizontal(player: Player, keys: Set<string>, dt: number): void {
const speed = SPEED * runFactor(keys) * dt
const fx = Math.sin(player.yaw)
const fz = -Math.cos(player.yaw)
const rx = Math.cos(player.yaw)
const rz = Math.sin(player.yaw)
const p = player.position
if (keys.has("KeyW")) {
p.x += fx * speed
p.z += fz * speed
}
if (keys.has("KeyS")) {
p.x -= fx * speed
p.z -= fz * speed
}
if (keys.has("KeyD")) {
p.x += rx * speed
p.z += rz * speed
}
if (keys.has("KeyA")) {
p.x -= rx * speed
p.z -= rz * speed
}
}
/** Push the player's circle out of any solid it overlaps: level colliders it
* is not standing above, and the NPC. This is what makes walls and the NPC
* impassable while still letting you stand on the crate. */
function collide(player: Player, level: Level): void {
for (const aabb of level.colliders) {
if (player.position.y < aabb.top - 0.01) {
pushFromAabb(player.position, aabb)
}
}
pushFromCircle(player.position, level.npcPosition.x, level.npcPosition.z, NPC_RADIUS)
}
/** Apply gravity and land on the highest ground under the player. */
function fall(player: Player, dt: number, level: Level): void {
player.velocityY -= GRAVITY * dt
player.position.y += player.velocityY * dt
const ground = groundHeight(player.position, level)
if (player.position.y <= ground) {
player.position.y = ground
player.velocityY = 0
player.onGround = true
} else {
player.onGround = false
}
}
function groundHeight(position: Vec3, level: Level): number {
let ground = Terrain.height(level.terrain, position.x, position.z)
for (const aabb of level.colliders) {
if (
aabb.standable &&
position.x >= aabb.minX &&
position.x <= aabb.maxX &&
position.z >= aabb.minZ &&
position.z <= aabb.maxZ
) {
ground = Math.max(ground, aabb.top)
}
}
return ground
}
function pushFromAabb(position: Vec3, aabb: Aabb): void {
const cx = Math.max(aabb.minX, Math.min(aabb.maxX, position.x))
const cz = Math.max(aabb.minZ, Math.min(aabb.maxZ, position.z))
const dx = position.x - cx
const dz = position.z - cz
const d2 = dx * dx + dz * dz
if (d2 >= RADIUS * RADIUS) {
return
}
if (d2 > 1e-6) {
const d = Math.sqrt(d2)
const push = (RADIUS - d) / d
position.x += dx * push
position.z += dz * push
return
}
// Center is inside the box: eject through the nearest face.
const left = position.x - aabb.minX
const rightSide = aabb.maxX - position.x
const near = position.z - aabb.minZ
const far = aabb.maxZ - position.z
const m = Math.min(left, rightSide, near, far)
if (m === left) {
position.x = aabb.minX - RADIUS
} else if (m === rightSide) {
position.x = aabb.maxX + RADIUS
} else if (m === near) {
position.z = aabb.minZ - RADIUS
} else {
position.z = aabb.maxZ + RADIUS
}
}
function pushFromCircle(position: Vec3, cx: number, cz: number, otherRadius: number): void {
const dx = position.x - cx
const dz = position.z - cz
const reach = RADIUS + otherRadius
const d2 = dx * dx + dz * dz
if (d2 >= reach * reach || d2 < 1e-6) {
return
}
const d = Math.sqrt(d2)
const push = (reach - d) / d
position.x += dx * push
position.z += dz * push
}
}

View file

@ -1,133 +0,0 @@
import { Framebuffer } from "../engine/render/Framebuffer"
import { Frustum } from "../engine/render/Frustum"
import { Rasterizer } from "../engine/render/Rasterizer"
import type { RenderConfig } from "../engine/render/RenderConfig"
import { Sky, type SkyConfig } from "../engine/render/Sky"
import type { Camera } from "../engine/scene/Camera"
import { Mat4 } from "../engine/math/Mat4"
import type { Mesh } from "../engine/scene/Mesh"
import { Mob, type MobKind } from "./actors/Mob"
import { Sprite } from "../engine/scene/Sprite"
import type { Vec2 } from "../engine/math/Vec2"
import type { Vec3 } from "../engine/math/Vec3"
import type { Textures } from "./textures"
import type { Chunk } from "./level"
/** Everything needed to render the world: the room, the cullable chunks, the NPC
* billboard source, sky, and textures. Bundled so it can be handed to a worker
* whole (it is plain data + typed arrays, structured-clone friendly). */
export type Scene = {
chunks: Chunk[]
floor: Mesh
walls: Mesh
crate: Mesh
npc: { position: Vec3; size: Vec2 }
/** Canonical local-space mob meshes, one per kind, built once + shared by every
* instance (each instance differs only by its per-frame model matrix). */
mobMesh: Record<MobKind, Mesh>
/** How many mobs the sim has -- sizes the worker's shared transform buffer. */
mobCount: number
sky: SkyConfig
textures: Textures
}
/** One mob's live transform for a frame: which mesh + where/how to place it.
* Produced by `visibleMobs` on the main thread, then either passed straight to
* `renderBand` (single-thread) or packed into the shared `mobState` buffer and
* rebuilt in each worker. `MOB_FLOATS` is that packed layout's stride. */
export type MobDraw = { kind: MobKind; x: number; y: number; z: number; heading: number; scale: number }
export const MOB_FLOATS = 6 // kind index (into MOB_KINDS), x, y, z, heading, scale
/** Chunk indices whose bounding box is inside the view frustum. Computed once on
* the main thread and shared with every worker (so they don't each re-cull). */
export function visibleChunks(chunks: Chunk[], viewProj: Mat4): number[] {
const frustum = Frustum.fromViewProj(viewProj)
const out: number[] = []
for (let i = 0; i < chunks.length; i++) {
const c = chunks[i]
if (Frustum.intersectsAabb(frustum, c.minX, c.minY, c.minZ, c.maxX, c.maxY, c.maxZ)) {
out.push(i)
}
}
return out
}
/** The `MobDraw`s for mobs whose world AABB is inside the view frustum. Mobs move,
* so (unlike chunks) they can't be baked into the culled world -- they're culled
* here per frame instead. Computed once on the main thread; the visible set is
* what gets shipped to the workers. */
export function visibleMobs(mobs: Mob[], viewProj: Mat4): MobDraw[] {
const frustum = Frustum.fromViewProj(viewProj)
const out: MobDraw[] = []
for (const m of mobs) {
const r = Mob.boundingRadius(m.kind) * m.scale
const h = Mob.bodyHeight(m.kind) * m.scale
const p = m.position
if (Frustum.intersectsAabb(frustum, p.x - r, p.y - r, p.z - r, p.x + r, p.y + h + r, p.z + r)) {
out.push({ kind: m.kind, x: p.x, y: p.y, z: p.z, heading: m.heading, scale: m.scale })
}
}
return out
}
/**
* Render rows [y0, y1) of one frame into `fb`. This is the single source of
* render truth: the single-thread path calls it with the full height, and each
* worker calls it with its own disjoint band -- same output either way, and no
* two bands touch the same pixel (so the shared framebuffer needs no locking).
*/
export function renderBand(
fb: Framebuffer,
scene: Scene,
camera: Camera,
viewProj: Mat4,
visible: number[],
mobDraws: MobDraw[],
config: RenderConfig,
skyStep: number,
time: number,
y0: number,
y1: number,
): void {
const tx = scene.textures
Sky.render(fb, camera, scene.sky, time, skyStep, y0, y1)
// Room: small and always near, drawn unconditionally (double-sided).
Rasterizer.draw(fb, scene.floor, tx.floor, viewProj, config, false, y0, y1)
Rasterizer.draw(fb, scene.walls, tx.wall, viewProj, config, false, y0, y1)
Rasterizer.draw(fb, scene.crate, tx.crate, viewProj, config, false, y0, y1)
for (const i of visible) {
const c = scene.chunks[i]
// Past lodDistance, draw the cheap impostor group set instead of full detail.
// `chunkFar` is pure (camera + chunk bounds + config), so every worker band
// makes the identical choice -- no full/impostor seam across bands. The loop
// is content-agnostic: each group carries its own mesh + material.
const groups = chunkFar(c, camera.position, config.lodDistance) ? c.far : c.near
for (const g of groups) {
Rasterizer.draw(fb, g.mesh, g.material.texture, viewProj, config, g.material.cull, y0, y1)
}
}
const sprite: Sprite = { position: scene.npc.position, size: scene.npc.size, texture: tx.npc }
Rasterizer.draw(fb, Sprite.billboard(sprite, camera), tx.npc, viewProj, config, false, y0, y1)
// Roaming mobs: each is the shared local-space mesh for its kind, placed by its
// own model matrix (viewProj x model). Drawn double-sided (cull off) -- they're
// small and few, so the winding-correct backface cull isn't worth the fuss.
for (const m of mobDraws) {
const mvp = Mat4.multiply(viewProj, Mat4.compose(m.x, m.y, m.z, m.heading, m.scale))
Rasterizer.draw(fb, scene.mobMesh[m.kind], tx[m.kind], mvp, config, false, y0, y1)
}
Framebuffer.quantize(fb, config, y0, y1)
}
/** Whether a chunk is far enough to draw its impostor meshes: squared distance
* from the camera to the chunk's AABB vs `lodDistance²`. Pure -- depends only on
* camera, the chunk's baked bounds, and the config constant, all of which every
* worker already holds, so the choice is identical across bands. */
export function chunkFar(chunk: Chunk, eye: Vec3, lodDistance: number): boolean {
if (!(lodDistance < Infinity)) {
return false
}
const dx = eye.x - Math.max(chunk.minX, Math.min(chunk.maxX, eye.x))
const dy = eye.y - Math.max(chunk.minY, Math.min(chunk.maxY, eye.y))
const dz = eye.z - Math.max(chunk.minZ, Math.min(chunk.maxZ, eye.z))
return dx * dx + dy * dy + dz * dz > lodDistance * lodDistance
}

View file

@ -2,8 +2,11 @@
"folders": [
{
"name": "meat",
"path": ".",
"path": "."
},
{
"path": "../meat.project"
}
],
"settings": {
"oxc.path.oxfmt": "node_modules/.bin/oxfmt",

38
tests/actors.test.ts Normal file
View file

@ -0,0 +1,38 @@
import { expect, test } from "bun:test"
import { Actor, type Actor as RuntimeActor, type ActorDefinition } from "../engine/scene/Actor"
type World = { updates: string[] }
const numberDefinition: ActorDefinition<{ value: number }, World> = {
prototype: { groups: [], radius: 1, minY: 0, maxY: 1 },
update: (state, _dt, world) => {
state.value++
world.updates.push(String(state.value))
},
transform: (state) => ({ x: state.value, y: 0, z: 0, heading: 0, scale: 1 }),
}
const textDefinition: ActorDefinition<{ value: string }, World> = {
prototype: { groups: [], radius: 1, minY: 0, maxY: 1 },
update: (state, _dt, world) => {
state.value += "!"
world.updates.push(state.value)
},
transform: (state) => ({ x: state.value.length, y: 0, z: 0, heading: 0, scale: 1 }),
}
test("one actor collection safely erases unrelated state types", () => {
const actors: RuntimeActor<World>[] = [
Actor.create(numberDefinition, { value: 1 }),
Actor.create(textDefinition, { value: "a" }),
]
const world: World = { updates: [] }
for (const actor of actors) {
Actor.update(actor, 1, world)
}
expect(world.updates).toEqual(["2", "a!"])
expect(Actor.transform(actors[0]).x).toBe(2)
expect(Actor.transform(actors[1]).x).toBe(2)
})

View file

@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { readdirSync, readFileSync, statSync } from "node:fs"
import { join } from "node:path"
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
import path from "node:path"
// The engine is the reusable, content-agnostic layer: it must import nothing from
// game (content) or app (browser glue). game -> engine and app -> game -> engine are
@ -9,7 +9,7 @@ import { join } from "node:path"
function tsFiles(dir: string): string[] {
const out: string[] = []
for (const name of readdirSync(dir)) {
const p = join(dir, name)
const p = path.join(dir, name)
if (statSync(p).isDirectory()) {
out.push(...tsFiles(p))
} else if (p.endsWith(".ts")) {
@ -19,14 +19,42 @@ function tsFiles(dir: string): string[] {
return out
}
function repositoryRoot(): string {
const parent = new URL("..", import.meta.url).pathname
return existsSync(path.join(parent, "app/renderer.ts")) ? parent : path.join(parent, "..")
}
function importsLayer(source: string, layers: string): boolean {
return new RegExp(`(?:from\\s+|import\\s*(?:\\(\\s*)?)["'][^"']*/(?:${layers})/`).test(source)
}
test("engine imports nothing from game or app", () => {
const engineDir = new URL("../engine", import.meta.url).pathname
const offenders = tsFiles(engineDir).filter((f) => /from\s+["'][^"']*\/(?:game|app)\//.test(readFileSync(f, "utf8")))
const engineDir = path.join(repositoryRoot(), "engine")
const offenders = tsFiles(engineDir).filter((file) => importsLayer(readFileSync(file, "utf8"), "game|app"))
expect(offenders).toEqual([])
})
test("game imports nothing from app", () => {
const gameDir = new URL("../game", import.meta.url).pathname
const offenders = tsFiles(gameDir).filter((f) => /from\s+["'][^"']*\/app\//.test(readFileSync(f, "utf8")))
const gameDir = path.join(repositoryRoot(), "game")
const offenders = tsFiles(gameDir).filter((file) => importsLayer(readFileSync(file, "utf8"), "app"))
expect(offenders).toEqual([])
})
test("render driver and worker know no game content", () => {
const root = repositoryRoot()
const files = [path.join(root, "app/renderer.ts"), path.join(root, "app/render-worker.ts")]
const offenders = files.filter((file) => importsLayer(readFileSync(file, "utf8"), "game"))
expect(offenders).toEqual([])
})
test("game exports no closed content-kind registries", () => {
const gameDir = path.join(repositoryRoot(), "game")
const offenders = tsFiles(gameDir).filter((file) => {
const source = readFileSync(file, "utf8")
return (
/\b(?:export\s+)?(?:type|enum)\s+\w+Kind\b/.test(source) ||
/\b(?:const|let|var)\s+\w*_(?:KINDS|REGISTRY)\b/.test(source)
)
})
expect(offenders).toEqual([])
})

72
tests/level.test.ts Normal file
View file

@ -0,0 +1,72 @@
import { expect, test } from "bun:test"
import type { Texture } from "../engine/render/Texture"
import { buildLevel } from "../game/level"
import type { Textures } from "../game/textures"
const texture: Texture = { width: 1, height: 1, data: new Uint32Array([0xFFFFFFFF]) }
const textures: Textures = {
floor: texture,
grass: texture,
bark: texture,
birch: texture,
leaf: texture,
needle: texture,
rock: texture,
flower: texture,
wall: texture,
crate: texture,
npc: texture,
frog: texture,
bee: texture,
robin: texture,
skybox: texture,
}
test("game data compiles into a clone-safe engine render scene", () => {
const level = buildLevel(textures)
expect(level.actors).toHaveLength(60)
expect(Object.isFrozen(level.actors)).toBe(true)
expect(Object.isFrozen(level.render)).toBe(true)
expect(Object.isFrozen(level.render.prototypes)).toBe(true)
expect(level.render.prototypes).toHaveLength(3)
expect(level.render.maxInstances).toBe(level.actors.length)
expect(() => structuredClone(level.render)).not.toThrow()
})
test("actor prototype bounds cover local geometry", () => {
const level = buildLevel(textures)
for (const prototype of level.render.prototypes) {
let covered = true
for (const group of prototype.groups) {
const vertices = group.mesh.verts
for (let i = 0; i < vertices.length; i += 5) {
covered &&=
Math.abs(vertices[i]) <= prototype.radius + 1e-6 &&
vertices[i + 1] >= prototype.minY - 1e-6 &&
vertices[i + 1] <= prototype.maxY + 1e-6 &&
Math.abs(vertices[i + 2]) <= prototype.radius + 1e-6
}
}
expect(covered).toBe(true)
}
})
test("chunk bounds cover geometry in every LOD", () => {
const level = buildLevel(textures)
for (const chunk of level.render.chunks) {
let covered = true
for (const group of [...chunk.near, ...chunk.far]) {
const vertices = group.mesh.verts
for (let i = 0; i < vertices.length; i += 5) {
covered &&=
vertices[i] >= chunk.minX &&
vertices[i] <= chunk.maxX &&
vertices[i + 1] >= chunk.minY &&
vertices[i + 1] <= chunk.maxY &&
vertices[i + 2] >= chunk.minZ &&
vertices[i + 2] <= chunk.maxZ
}
}
expect(covered).toBe(true)
}
})

View file

@ -1,21 +0,0 @@
import { expect, test } from "bun:test"
import { MOB_KINDS, Mob } from "../game/actors/Mob"
// The mob SAB packs a kind as its index in MOB_KINDS; the main thread and every
// render worker must agree on that order. Freeze it here: appending a kind is fine,
// but reordering or removing an existing one silently corrupts which mesh/texture a
// worker draws.
test("MOB_KINDS order is frozen (mob SAB ids)", () => {
expect(MOB_KINDS).toEqual(["frog", "bee", "robin"])
})
test("every kind resolves to a complete definition", () => {
for (const kind of MOB_KINDS) {
const d = Mob.def(kind)
expect(d.name).toBe(kind)
expect(typeof d.build).toBe("function")
expect(typeof d.update).toBe("function")
expect(d.boundingRadius).toBeGreaterThan(0)
expect(d.bodyHeight).toBeGreaterThan(0)
}
})

View file

@ -0,0 +1,38 @@
import { expect, test } from "bun:test"
import { RenderProtocol } from "../engine/render/RenderProtocol"
import type { RenderInstance } from "../engine/render/RenderScene"
import type { Camera } from "../engine/scene/Camera"
test("render camera protocol owns its complete shared layout", () => {
const input: Camera = { position: { x: 1, y: 2, z: 3 }, yaw: 4, pitch: 5, fov: 6 }
const data = new Float64Array(RenderProtocol.CAMERA_LENGTH)
RenderProtocol.writeCamera(data, input, 7)
const output = RenderProtocol.readCamera(data)
expect(output).toEqual({ camera: input, time: 7 })
expect(RenderProtocol.VIEW_PROJECTION_LENGTH).toBe(16)
})
test("render instance protocol round-trips scene-local prototype indexes", () => {
const input: RenderInstance[] = [
{ prototype: 2, x: 1.25, y: -2, z: 3.5, heading: 0.75, scale: 1.5 },
{ prototype: 0, x: -4, y: 5.25, z: 6, heading: -0.5, scale: 0.625 },
]
const ids = new Int32Array(2)
const transforms = new Float32Array(2 * RenderProtocol.TRANSFORM_FLOATS)
const output: RenderInstance[] = []
const count = RenderProtocol.writeInstances(ids, transforms, input)
RenderProtocol.readInstances(ids, transforms, count, output)
expect(output).toHaveLength(input.length)
for (let i = 0; i < input.length; i++) {
expect(output[i].prototype).toBe(input[i].prototype)
expect(output[i].x).toBeCloseTo(input[i].x)
expect(output[i].y).toBeCloseTo(input[i].y)
expect(output[i].z).toBeCloseTo(input[i].z)
expect(output[i].heading).toBeCloseTo(input[i].heading)
expect(output[i].scale).toBeCloseTo(input[i].scale)
}
})

View file

@ -1,18 +0,0 @@
import { expect, test } from "bun:test"
import { Tree, TREE_KINDS } from "../game/actors/Tree"
// The chunk baker (level.ts) accumulates geometry into a mesh per material key and
// only draws keys listed in MAT_ORDER. A tree species that declares a trunk/foliage
// material outside that palette would bake geometry that is silently never drawn.
// Freeze the palette here so a typo'd or unregistered material key fails a test.
const MATERIAL_KEYS = new Set(["grass", "rock", "bark", "birch", "leaf", "needle", "flower"])
test("every tree species maps to known chunk materials", () => {
for (const kind of TREE_KINDS) {
const s = Tree.species(kind)
expect(s.kind).toBe(kind)
expect(typeof s.build).toBe("function")
expect(MATERIAL_KEYS.has(s.trunk)).toBe(true)
expect(MATERIAL_KEYS.has(s.foliage)).toBe(true)
}
})