refactor: move world concepts into engine
This commit is contained in:
parent
eeedcb8e48
commit
2d15c7ab8d
52 changed files with 3298 additions and 1558 deletions
|
|
@ -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
714
.agents/plans/gpu.md
Normal 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.
|
||||||
|
|
@ -1,9 +1,18 @@
|
||||||
# Levels & the in-game editor — direction note
|
# 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
|
session starts from the conclusion, not a cold read. Nothing here is committed
|
||||||
code; it's the shape we want and *why*.
|
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 pivot
|
||||||
|
|
||||||
The game is heading toward a **multiplayer twitch shooter**. Each match loads a
|
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
|
- **Persistence and player carry-over are YAGNI** for now — but must not be walled
|
||||||
off (see hedges).
|
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
|
Agreed vocabulary — reads as **"bake a `Level` into a `Scene`."** Fits the
|
||||||
type + namespace convention.
|
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
|
- **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.
|
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
|
- `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**
|
in `app/main.ts`. Today's `Level` (`game/level.ts:69-81`) is a **bake result**
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
../.agents/skills
|
|
||||||
143
AGENTS.md
143
AGENTS.md
|
|
@ -26,14 +26,19 @@ rules live in `.agents/rules/*.md`.
|
||||||
frame; on-screen solids also **backface-cull**. This is what keeps a dense world
|
frame; on-screen solids also **backface-cull**. This is what keeps a dense world
|
||||||
(thousands of trees/rocks) affordable — off-screen content costs ~nothing.
|
(thousands of trees/rocks) affordable — off-screen content costs ~nothing.
|
||||||
- **2D assets only.** Sprites/billboards (PS1-style), **no 3D model loading**.
|
- **2D assets only.** Sprites/billboards (PS1-style), **no 3D model loading**.
|
||||||
- **Three layers, one-way deps: `engine` ← `game` ← `app`.** `engine/` is the
|
- **Three layers, one-way deps: `engine` ← `game` ← `app`.** `engine/` owns
|
||||||
reusable, content-agnostic **mechanism** (rasterizer, framebuffer, culling, the
|
reusable, content-agnostic **concepts and mechanisms**: levels, terrain, actors,
|
||||||
`Actor`/`Material` interfaces) — no DOM, no game content, could run server-side.
|
prefabs, collision, render scenes, chunking/LOD, rasterization, and worker render
|
||||||
`game/` is **this game's content** (the creatures/props, terrain, level, scene
|
transport. `game/` owns **this game's concrete content**: actual level values,
|
||||||
assembly + render orchestration) built on the engine interfaces. `app/` is
|
placements, materials, and frog/bee/robin or oak/spruce/birch definitions.
|
||||||
**browser glue** (canvas, input, image decode, the worker render driver + loop).
|
`app/` is browser glue (canvas, input mapping, image decode, workers, frame loop).
|
||||||
Enforced: `engine/` imports nothing from `game`/`app`, `game/` nothing from `app`
|
Enforced: `engine/` imports nothing from `game`/`app`, `game/` nothing from `app`
|
||||||
(`tests/layering.test.ts`). Both `engine` and `game` are DOM-free (tsconfig).
|
(`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
|
## 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
|
- `bunx tsc --build tsconfig.app.json` — **typecheck the engine+game+app graph. Use
|
||||||
this**, not `bun run check` (see Caveats).
|
this**, not `bun run check` (see Caveats).
|
||||||
- `bunx oxlint engine game app` — lint.
|
- `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).
|
- `bun run serve` — Bun server (`server/server.ts`, a stub for now).
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
@ -71,73 +76,51 @@ rules live in `.agents/rules/*.md`.
|
||||||
(optional backface cull per draw), `Frustum` (6 planes from the viewProj +
|
(optional backface cull per draw), `Frustum` (6 planes from the viewProj +
|
||||||
AABB test, for chunk culling), `Texture` (nearest/bilinear, wrapping, no
|
AABB test, for chunk culling), `Texture` (nearest/bilinear, wrapping, no
|
||||||
mipmaps), `Material` (texture + cull flag; a `DrawGroup` pairs a mesh with one,
|
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 +
|
so the renderer draws by list, not by named texture), `Chunk`/`ChunkBuilder`
|
||||||
procedural clouds; renders at 1/`step` res).
|
(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),
|
- `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,
|
`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`
|
no per-vertex objects — cache-friendly + alloc-free to draw), `Sprite`
|
||||||
(Y-axis billboard), `Actor` (the generic `Entity<State, World>` interface —
|
(Y-axis billboard), `MeshBuilder` (generic quad/slab/box construction), `Actor`
|
||||||
build + update + bounds — that a content kind implements; the engine dispatches
|
(open behavior + render/collider contract), and `Prefab` (open static-content
|
||||||
through it, never a `kind` switch).
|
contract + type-erased placed value).
|
||||||
- `game/` — this game's content + world assembly, on the engine interfaces
|
- `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`).
|
(headless: no DOM, imports nothing from `app`).
|
||||||
- `actors/` — the placeable things. `Mob` (a **roaming** creature — `frog` hops
|
- `actors/` — the placeable things. `Mob` (a **roaming** creature — `frog` hops
|
||||||
the ground, `bee` hovers/darts, `robin` mostly hops but now and then takes a
|
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
|
short powered flight — the only moving geometry; each module exports an
|
||||||
`mobs/<Kind>.ts` + shared `mobs/mobkit.ts`, assembled by the thin `Mob` registry.
|
`ActorDefinition` factory and instances hold the resulting object directly.
|
||||||
Its local-space mesh is built once per kind; `Mob.update` steps the wander AI
|
`Tree` is shared placement state; Oak/Spruce/Birch modules export material-bound
|
||||||
(leashed to a home anchor, deterministic per evolving `seed`) each frame and the
|
`Prefab<Tree>` factories with no species registry. `Boulder`
|
||||||
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`
|
|
||||||
(squashed jittered part-buried sphere), `Bush` (leaf-blob cluster, shares the
|
(squashed jittered part-buried sphere), `Bush` (leaf-blob cluster, shares the
|
||||||
leaf mesh), `Flower` (stem + colored bloom, 2x2 atlas, double-sided). Baked props
|
leaf mesh), `Flower` (stem + colored bloom, 2x2 atlas, double-sided). Baked props
|
||||||
append into shared per-material meshes; mobs draw live.
|
append into shared per-material meshes; mobs draw live.
|
||||||
- `Terrain.ts` — procedural heightfield around the room (flat clearing, rolling
|
- `level.ts` — concrete playground values and placement policy: room dimensions,
|
||||||
hills, tall edge peaks). `Terrain.patch` builds one ground patch over a rectangle
|
rolling-terrain parameters, counts/seeds/reach, material bindings, sky, and
|
||||||
(per chunk, welds crack-free, hole for the room); `Terrain.height` is the shared
|
spawn definition lists. It places direct `Prefab` objects, submits the resulting
|
||||||
ground sampler for the player + mobs.
|
values to engine `ChunkBuilder`, then creates engine `Level`; no game renderer or material-key
|
||||||
- `level.ts` — builds the playground: a flat stone-floored room (three thick
|
registry exists. Trees + boulders bake full and far-impostor geometry; bushes
|
||||||
walls via `slab`, north side open) always drawn, in the center of a big grassy
|
and flowers provide near geometry only.
|
||||||
`Terrain` world (~20x across). Props are placed first (`placeTrees` /
|
- `player.ts` — concrete player tuning only; movement and collision live in
|
||||||
`placeBoulders` / `placeBushes` / `placeFlowers` → instance lists + colliders;
|
engine `CharacterController`.
|
||||||
`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).
|
|
||||||
- `app/` — browser glue only (top layer; depends on `game` + `engine`).
|
- `app/` — browser glue only (top layer; depends on `game` + `engine`).
|
||||||
- `main.ts` — game loop: input, sim, preset switching, per-frame culling, then
|
- `main.ts` — game loop: input, sim, preset switching, per-frame culling, then
|
||||||
the non-blocking pump (`renderer.dispatch`/`done`) + `present` (GPU/CSS upscale)
|
the non-blocking pump (`renderer.dispatch`/`done`) + `present` (GPU/CSS upscale)
|
||||||
+ a multi-line frame HUD (`work + present` critical-path ms, vsync, visible
|
+ 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,
|
chunks / LOD-aware tris). Calls engine `Level` simulation/collider extraction,
|
||||||
rebuilds near-player mob colliders into `level.colliders`, culls mobs
|
maps browser keys into `CharacterInput`, and dispatches visible render instances.
|
||||||
(`visibleMobs`) so only visible transforms dispatch.
|
|
||||||
- `renderer.ts` — the render driver. When the page is cross-origin-isolated it runs
|
- `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`
|
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`
|
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
|
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 +
|
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
|
visible engine instance prototype indexes + transforms. `?bench=st|mt` A/Bs
|
||||||
the paths.
|
the paths. Renderer and worker import no game modules.
|
||||||
- `assets.ts` — load `/assets/*.png` → `Texture` (zero-copy; ImageData bytes are
|
- `assets.ts` — load `/assets/*.png` → `Texture` (zero-copy; ImageData bytes are
|
||||||
already the `Color` layout); returns the `game` `Textures` palette.
|
already the `Color` layout); returns the `game` `Textures` palette.
|
||||||
- `index.html` — Vite entry at repo root; holds the `#screen` canvas and the
|
- `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`)
|
## Frame pipeline (`app/main.ts` `tick`)
|
||||||
|
|
||||||
`Mob.update` (all mobs) + rebuild near-player mob colliders → `Player.update` →
|
`Level.update` (all actors) + `Level.refreshActorColliders` →
|
||||||
build `Camera` → `Camera.viewProjection` → `visibleChunks` + `visibleMobs`
|
`CharacterController.update` → build `Camera` → `Camera.viewProjection` →
|
||||||
(frustum-cull, once on the main thread) → `renderer.dispatch` (non-blocking) →
|
`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
|
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
|
while N+1 is dispatched; the pump never blocks or async-awaits, so it can't
|
||||||
desync from rAF.
|
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
|
`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.
|
whole window (cost grew with window size); present is now ~0.2ms.
|
||||||
|
|
||||||
`renderBand` runs `renderScene.renderBand` for rows [y0,y1): `Sky.render` at
|
`renderBand` runs `RenderScene.renderBand` for rows [y0,y1): `Sky.render` with
|
||||||
1/`SKY_STEP` res (fills color + resets depth, replaces a clear) → `Rasterizer.draw`
|
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 —
|
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
|
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
|
+ 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)
|
`Material` (cull per-material, so solids backface-cull and flowers stay double-sided)
|
||||||
→ `Sprite.billboard(npc)` →
|
→ each billboard → each visible actor prototype instance (shared local draw groups ×
|
||||||
the roaming mobs (each: shared local mesh × its `Mat4.compose` model matrix,
|
its `Mat4.compose` model matrix) → `Framebuffer.quantize`. `Chunk.isFar` is pure (camera + baked bounds + config
|
||||||
double-sided) → `Framebuffer.quantize`. `chunkFar` is pure (camera + baked bounds + config
|
|
||||||
only), so every worker band picks the same LOD for a chunk → no horizontal seam.
|
only), so every worker band picks the same LOD for a chunk → no horizontal seam.
|
||||||
Multi-threaded: N workers each run `renderBand` over
|
Multi-threaded: N workers each run `renderBand` over
|
||||||
their band of the shared framebuffer in parallel; single-threaded: one call 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.
|
chunks that fall outside the view. Behind you + off to the sides = free.
|
||||||
- **Backface culling** (`draw(..., true)`) — ~halves fill on solid geometry
|
- **Backface culling** (`draw(..., true)`) — ~halves fill on solid geometry
|
||||||
(terrain, foliage, rock). See the Rasterizer note re winding.
|
(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.
|
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
|
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
|
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
|
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/`)
|
## Trees (`game/actors/Tree.ts` + `game/actors/trees/`)
|
||||||
|
|
||||||
Procedural low-poly geometry, faceted flat-shaded like everything else. Each species
|
Procedural low-poly geometry, faceted flat-shaded like everything else. `Tree.ts`
|
||||||
is a `TreeSpecies` definition in its own `trees/<Kind>.ts` module (geometry +
|
contains only shared placement state. Oak, Spruce, and Birch each expose a concrete
|
||||||
which chunk materials its trunk/foliage bake into); `Tree.ts` just assembles them
|
`Prefab<Tree>` factory from their own module; level data references those definition
|
||||||
into a registry (`Tree.species(kind)`, `TREE_KINDS`) and shared primitives live in
|
objects directly, and shared geometry primitives live in `trees/treekit.ts`.
|
||||||
`trees/treekit.ts`. Three `kind`s carry the species read purely by silhouette:
|
|
||||||
- **`oak`** — short tapered trunk, a couple of branches, a broad cluster of
|
- **`oak`** — short tapered trunk, a couple of branches, a broad cluster of
|
||||||
lumpy canopy `blob`s (wider than tall, bushy).
|
lumpy canopy `blob`s (wider than tall, bushy).
|
||||||
- **`spruce`** — tall thin trunk under stacked narrowing `cone` tiers pointing
|
- **`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
|
`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.
|
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
|
Each factory receives concrete trunk/foliage `Material` objects and closes over
|
||||||
`birch` trunk, oak `leaf` foliage); the chunk baker (`level.ts`) accumulates one
|
them. `ChunkBuilder` accumulates meshes by material object, so a forest still
|
||||||
mesh per material key and routes each tree via `Tree.species(kind)` — so a forest
|
batches into a few draw groups without string keys or a registry. `game/level.ts`
|
||||||
still batches into a few draw calls and the baker names no texture. `game/level.ts`
|
`placeTrees` selects from a weighted list of prefab objects. **Add a species** =
|
||||||
`placeTrees` seeds the forest and rolls the species. **Add a species** = add a
|
add one concrete prefab module and include its object in level data.
|
||||||
`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`.
|
|
||||||
|
|
||||||
**Boulders** (`game/actors/Boulder.ts`) work the same way: `Boulder.build`
|
**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
|
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
|
## 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
|
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
|
presets. FPS shown bottom-right. The room's north wall is open — walk out onto
|
||||||
the terrain and toward the peaks.
|
the terrain and toward the peaks.
|
||||||
|
|
@ -349,7 +328,7 @@ job tmp dir, not the repo.
|
||||||
## Roadmap / not yet built
|
## Roadmap / not yet built
|
||||||
|
|
||||||
In-browser RenderConfig slider panel; mipmaps; `painter` depth mode; gouraud
|
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/`
|
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
|
is nearly empty. The FPS meter is static HTML + `textContent` writes only — no
|
||||||
DOM-built UI yet (deliberate).
|
DOM-built UI yet (deliberate).
|
||||||
|
|
|
||||||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -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
29
CONTEXT.md
Normal 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
|
||||||
205
app/main.ts
205
app/main.ts
|
|
@ -1,20 +1,18 @@
|
||||||
import { RenderConfig } from "../engine/render/RenderConfig"
|
import { RenderConfig } from "../engine/render/RenderConfig"
|
||||||
|
import { RenderScene, type RenderInstance } from "../engine/render/RenderScene"
|
||||||
import { Camera } from "../engine/scene/Camera"
|
import { Camera } from "../engine/scene/Camera"
|
||||||
import type { Mesh } from "../engine/scene/Mesh"
|
import {
|
||||||
import { Mob, MOB_KINDS, type MobKind } from "../game/actors/Mob"
|
CharacterController,
|
||||||
import type { Vec3 } from "../engine/math/Vec3"
|
type CharacterInput,
|
||||||
|
} from "../engine/world/CharacterController"
|
||||||
|
import { Level } from "../engine/world/Level"
|
||||||
import { loadTextures } from "./assets"
|
import { loadTextures } from "./assets"
|
||||||
import { buildLevel, type Level } from "../game/level"
|
import { buildLevel } from "../game/level"
|
||||||
import { EYE_HEIGHT, Player } from "../game/player"
|
import { Player, type Player as PlayerState } from "../game/player"
|
||||||
import { createRenderer } from "./renderer"
|
import { createRenderer } from "./renderer"
|
||||||
import { chunkFar, visibleChunks, visibleMobs, type Scene } from "../game/renderScene"
|
|
||||||
|
|
||||||
const FOV_DEGREES = 75
|
const FOV_DEGREES = 75
|
||||||
const FOV = (FOV_DEGREES * Math.PI) / 180
|
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 screen = document.querySelector<HTMLCanvasElement>("#screen")!
|
||||||
const ctx = screen.getContext("2d")!
|
const ctx = screen.getContext("2d")!
|
||||||
|
|
@ -28,7 +26,11 @@ function benchCamera(tv: number): Camera {
|
||||||
const drift = tv * 0.12
|
const drift = tv * 0.12
|
||||||
const radius = 65 + 30 * Math.sin(tv * 0.25)
|
const radius = 65 + 30 * Math.sin(tv * 0.25)
|
||||||
return {
|
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,
|
yaw: tv * 0.7,
|
||||||
pitch: 0.05 * Math.sin(tv * 0.5),
|
pitch: 0.05 * Math.sin(tv * 0.5),
|
||||||
fov: FOV,
|
fov: FOV,
|
||||||
|
|
@ -39,41 +41,34 @@ function round2(n: number): number {
|
||||||
return Math.round(n * 100) / 100
|
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 s = a.toSorted((x, y) => x - y)
|
||||||
const q = (p: number): number => s[Math.min(s.length - 1, Math.floor(p * s.length))]
|
const q = (p: number): number =>
|
||||||
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) }
|
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> {
|
async function main(): Promise<void> {
|
||||||
const textures = await loadTextures()
|
const textures = await loadTextures()
|
||||||
const level = buildLevel(textures)
|
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.
|
// `?bench=st` / `?bench=mt` runs a scripted flythrough and reports timings.
|
||||||
const benchMode = new URLSearchParams(globalThis.location.search).get("bench")
|
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
|
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 image = new ImageData(renderer.fb.width, renderer.fb.height)
|
||||||
let colorBytes = new Uint8ClampedArray(renderer.fb.color.buffer)
|
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.
|
// letterboxed upscale, done by the GPU). Recomputed only on resize/config.
|
||||||
function layout(): void {
|
function layout(): void {
|
||||||
const fb = renderer.fb
|
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 w = fb.width * scale
|
||||||
const h = fb.height * scale
|
const h = fb.height * scale
|
||||||
screen.style.width = `${w}px`
|
screen.style.width = `${w}px`
|
||||||
screen.style.height = `${h}px`
|
screen.style.height = `${h}px`
|
||||||
screen.style.left = `${(globalThis.innerWidth - w) >> 1}px`
|
screen.style.left = `${(globalThis.innerWidth - w) >> 1}px`
|
||||||
screen.style.top = `${(globalThis.innerHeight - h) >> 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()
|
retarget()
|
||||||
|
|
||||||
function useConfig(next: RenderConfig): void {
|
function useConfig(next: RenderConfig): void {
|
||||||
config = next
|
config = next
|
||||||
renderer.reconfigure(next)
|
renderer.reconfigure(next)
|
||||||
|
inFlight = false
|
||||||
retarget()
|
retarget()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,9 +127,7 @@ async function main(): Promise<void> {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const player: Player = { position: { x: 0, y: 0, z: 8 }, yaw: 0, pitch: 0, velocityY: 0, onGround: true }
|
const player: PlayerState = Player.create()
|
||||||
// Colliders past this index are the dynamic mob ones, rebuilt every frame.
|
|
||||||
const staticColliderCount = level.colliders.length
|
|
||||||
const keys = new Set<string>()
|
const keys = new Set<string>()
|
||||||
globalThis.addEventListener("keydown", (e) => {
|
globalThis.addEventListener("keydown", (e) => {
|
||||||
keys.add(e.code)
|
keys.add(e.code)
|
||||||
|
|
@ -154,26 +157,30 @@ async function main(): Promise<void> {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
player.yaw += e.movementX * 0.0025
|
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.
|
// Triangles drawn this frame (room + each visible chunk, LOD-aware) for the HUD.
|
||||||
function frameTris(visible: number[], cam: Camera): number {
|
function frameTris(
|
||||||
let t = level.floor.indices.length + level.walls.indices.length + level.crate.indices.length
|
visible: number[],
|
||||||
for (const i of visible) {
|
instances: RenderInstance[],
|
||||||
const c = level.chunks[i]
|
camera: Camera,
|
||||||
const groups = chunkFar(c, cam.position, config.lodDistance) ? c.far : c.near
|
): number {
|
||||||
for (const g of groups) {
|
return RenderScene.triangleCount(
|
||||||
t += g.mesh.indices.length
|
level.render,
|
||||||
}
|
visible,
|
||||||
}
|
instances,
|
||||||
return (t / 3) | 0
|
camera.position,
|
||||||
|
config.lodDistance,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll-based pump: present the finished frame, dispatch the next; if workers
|
// 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
|
// 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.
|
// critical-path budget (work + present) so the real bottleneck is visible.
|
||||||
let inFlight = false
|
|
||||||
let last = performance.now()
|
let last = performance.now()
|
||||||
let fpsLast = last
|
let fpsLast = last
|
||||||
let fpsFrames = 0
|
let fpsFrames = 0
|
||||||
|
|
@ -182,7 +189,13 @@ async function main(): Promise<void> {
|
||||||
let vsyncMax = 0
|
let vsyncMax = 0
|
||||||
let lastPresent = performance.now()
|
let lastPresent = performance.now()
|
||||||
let lastVisible: number[] = []
|
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 {
|
function show(): void {
|
||||||
const p0 = performance.now()
|
const p0 = performance.now()
|
||||||
|
|
@ -213,30 +226,42 @@ async function main(): Promise<void> {
|
||||||
fpsEl.textContent =
|
fpsEl.textContent =
|
||||||
`${fps} fps${tag}\n` +
|
`${fps} fps${tag}\n` +
|
||||||
`work ${round2(workMax)} + present ${round2(presentMax)} = ${round2(workMax + presentMax)}ms\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
|
fpsLast = now
|
||||||
fpsFrames = 0
|
fpsFrames = 0
|
||||||
workMax = 0
|
workMax = 0
|
||||||
presentMax = 0
|
presentMax = 0
|
||||||
vsyncMax = 0
|
vsyncMax = 0
|
||||||
}
|
}
|
||||||
for (const m of level.mobs) {
|
Level.update(level, dt)
|
||||||
Mob.update(m, dt, level.terrain)
|
Level.refreshActorColliders(level, player.position, Player.actorCollisionRange)
|
||||||
}
|
CharacterController.update(
|
||||||
rebuildMobColliders(level, player.position, staticColliderCount)
|
player,
|
||||||
Player.update(player, keys, dt, level)
|
readPlayerInput(keys),
|
||||||
|
dt,
|
||||||
|
level.collision,
|
||||||
|
Player.config,
|
||||||
|
)
|
||||||
const camera: Camera = {
|
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,
|
yaw: player.yaw,
|
||||||
pitch: player.pitch,
|
pitch: player.pitch,
|
||||||
fov: FOV,
|
fov: FOV,
|
||||||
}
|
}
|
||||||
const viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height)
|
const viewProj = Camera.viewProjection(
|
||||||
const visible = visibleChunks(level.chunks, viewProj)
|
camera,
|
||||||
const mobDraws = visibleMobs(level.mobs, viewProj)
|
renderer.fb.width / renderer.fb.height,
|
||||||
|
)
|
||||||
|
const visible = RenderScene.visibleChunks(level.render, viewProj)
|
||||||
|
const instances = Level.visibleInstances(level, viewProj)
|
||||||
lastVisible = visible
|
lastVisible = visible
|
||||||
|
lastInstances = instances
|
||||||
lastCamera = camera
|
lastCamera = camera
|
||||||
renderer.dispatch(camera, viewProj, visible, now / 1000, mobDraws)
|
renderer.dispatch(camera, viewProj, visible, now / 1000, instances)
|
||||||
inFlight = true
|
inFlight = true
|
||||||
if (renderer.done()) {
|
if (renderer.done()) {
|
||||||
show()
|
show()
|
||||||
|
|
@ -250,7 +275,7 @@ async function main(): Promise<void> {
|
||||||
* interval, then reports the distributions (exposed on `window.__BENCH__`). */
|
* interval, then reports the distributions (exposed on `window.__BENCH__`). */
|
||||||
function runBench(
|
function runBench(
|
||||||
renderer: ReturnType<typeof createRenderer>,
|
renderer: ReturnType<typeof createRenderer>,
|
||||||
level: Level,
|
level: ReturnType<typeof buildLevel>,
|
||||||
present: () => void,
|
present: () => void,
|
||||||
mode: string,
|
mode: string,
|
||||||
): void {
|
): void {
|
||||||
|
|
@ -282,7 +307,9 @@ function runBench(
|
||||||
mode,
|
mode,
|
||||||
parallel: renderer.parallel,
|
parallel: renderer.parallel,
|
||||||
cores: (globalThis.navigator as Navigator).hardwareConcurrency,
|
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}`,
|
res: `${renderer.fb.width}x${renderer.fb.height}`,
|
||||||
workMs: benchStats(work),
|
workMs: benchStats(work),
|
||||||
frameMs: benchStats(frame),
|
frameMs: benchStats(frame),
|
||||||
|
|
@ -306,14 +333,15 @@ function runBench(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const m of level.mobs) {
|
Level.update(level, 1 / 60)
|
||||||
Mob.update(m, 1 / 60, level.terrain)
|
|
||||||
}
|
|
||||||
const camera = benchCamera(i / 60)
|
const camera = benchCamera(i / 60)
|
||||||
const viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height)
|
const viewProj = Camera.viewProjection(
|
||||||
const visible = visibleChunks(level.chunks, viewProj)
|
camera,
|
||||||
const mobDraws = visibleMobs(level.mobs, viewProj)
|
renderer.fb.width / renderer.fb.height,
|
||||||
renderer.dispatch(camera, viewProj, visible, i / 60, mobDraws)
|
)
|
||||||
|
const visible = RenderScene.visibleChunks(level.render, viewProj)
|
||||||
|
const instances = Level.visibleInstances(level, viewProj)
|
||||||
|
renderer.dispatch(camera, viewProj, visible, i / 60, instances)
|
||||||
inFlight = true
|
inFlight = true
|
||||||
if (renderer.done() && record()) {
|
if (renderer.done() && record()) {
|
||||||
report()
|
report()
|
||||||
|
|
@ -322,29 +350,12 @@ function runBench(
|
||||||
requestAnimationFrame(tick)
|
requestAnimationFrame(tick)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rebuild the dynamic tail of `level.colliders`: keep the static prefix, then add
|
function readPlayerInput(keys: Set<string>): CharacterInput {
|
||||||
* a block/stand-on AABB for each mob near the player. Mobs move, so these can't be
|
return {
|
||||||
* baked. Only mobs on the ground are `standable` (hop onto a resting frog/perched
|
forward: (keys.has("KeyW") ? 1 : 0) - (keys.has("KeyS") ? 1 : 0),
|
||||||
* robin); bees and airborne birds still block but never make a mid-air platform.
|
right: (keys.has("KeyD") ? 1 : 0) - (keys.has("KeyA") ? 1 : 0),
|
||||||
* Only mobs within `MOB_COLLIDE_RANGE` are added -- the rest can't be reached this
|
jump: keys.has("Space"),
|
||||||
* frame anyway. */
|
run: keys.has("ShiftLeft") || keys.has("ShiftRight"),
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,12 @@
|
||||||
import type { Framebuffer } from "../engine/render/Framebuffer"
|
import type { Framebuffer } from "../engine/render/Framebuffer"
|
||||||
import type { RenderConfig } from "../engine/render/RenderConfig"
|
import { RenderProtocol, type RenderWorkerInit } from "../engine/render/RenderProtocol"
|
||||||
import { MOB_KINDS } from "../game/actors/Mob"
|
import { RenderScene, type RenderInstance } from "../engine/render/RenderScene"
|
||||||
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
|
|
||||||
|
|
||||||
const ctx = globalThis as unknown as {
|
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) => {
|
ctx.addEventListener("message", (e) => {
|
||||||
|
|
@ -41,31 +18,46 @@ ctx.addEventListener("message", (e) => {
|
||||||
depth: new Float32Array(m.depthSAB),
|
depth: new Float32Array(m.depthSAB),
|
||||||
}
|
}
|
||||||
const ctrl = new Int32Array(m.ctrlSAB)
|
const ctrl = new Int32Array(m.ctrlSAB)
|
||||||
const cam = new Float64Array(m.camSAB)
|
const cameraData = new Float64Array(m.cameraSAB)
|
||||||
const vp = new Float32Array(m.vpSAB)
|
const viewProjection = new Float32Array(m.viewProjectionSAB)
|
||||||
const vis = new Int32Array(m.visSAB)
|
const visibleChunks = new Int32Array(m.visibleChunkSAB)
|
||||||
const mob = new Float32Array(m.mobSAB)
|
const instanceIds = new Int32Array(m.instanceIdSAB)
|
||||||
|
const instanceTransforms = new Float32Array(m.instanceTransformSAB)
|
||||||
const times = new Float64Array(m.timesSAB)
|
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
|
// 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.
|
// band, record the band time, and signal done. No messages per frame.
|
||||||
let last = 0
|
let last = 0
|
||||||
for (;;) {
|
for (;;) {
|
||||||
Atomics.wait(ctrl, FRAME, last)
|
Atomics.wait(ctrl, RenderProtocol.FRAME, last)
|
||||||
last = Atomics.load(ctrl, FRAME)
|
last = Atomics.load(ctrl, RenderProtocol.FRAME)
|
||||||
const t0 = performance.now()
|
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 frameCamera = RenderProtocol.readCamera(cameraData)
|
||||||
const count = Atomics.load(ctrl, VIS)
|
const count = Atomics.load(ctrl, RenderProtocol.VISIBLE_CHUNKS)
|
||||||
const visible = [...vis.subarray(0, count)]
|
const visible = [...visibleChunks.subarray(0, count)]
|
||||||
const mobCount = Atomics.load(ctrl, MOBVIS)
|
const instanceCount = Atomics.load(ctrl, RenderProtocol.VISIBLE_INSTANCES)
|
||||||
const mobDraws: MobDraw[] = []
|
RenderProtocol.readInstances(
|
||||||
for (let i = 0; i < mobCount; i++) {
|
instanceIds,
|
||||||
const o = i * MOB_FLOATS
|
instanceTransforms,
|
||||||
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] })
|
instanceCount,
|
||||||
}
|
instances,
|
||||||
renderBand(fb, scene, camera, vp, visible, mobDraws, config, skyStep, cam[6], band[0], band[1])
|
)
|
||||||
times[index] = performance.now() - t0
|
RenderScene.renderBand(
|
||||||
Atomics.add(ctrl, DONE, 1)
|
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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
228
app/renderer.ts
228
app/renderer.ts
|
|
@ -2,8 +2,8 @@ import { Framebuffer } from "../engine/render/Framebuffer"
|
||||||
import type { RenderConfig } from "../engine/render/RenderConfig"
|
import type { RenderConfig } from "../engine/render/RenderConfig"
|
||||||
import type { Mat4 } from "../engine/math/Mat4"
|
import type { Mat4 } from "../engine/math/Mat4"
|
||||||
import type { Camera } from "../engine/scene/Camera"
|
import type { Camera } from "../engine/scene/Camera"
|
||||||
import { MOB_KINDS } from "../game/actors/Mob"
|
import { RenderProtocol, type RenderWorkerInit } from "../engine/render/RenderProtocol"
|
||||||
import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "../game/renderScene"
|
import { RenderScene, type RenderInstance } from "../engine/render/RenderScene"
|
||||||
|
|
||||||
/** Clouds are drawn at 1/SKY_STEP resolution (the sky base + sun stay per-pixel);
|
/** 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. */
|
* band splits align to it so the cloud block grid stays seamless across workers. */
|
||||||
|
|
@ -19,12 +19,6 @@ const ENABLE_WORKERS = true
|
||||||
* bench. */
|
* bench. */
|
||||||
const MAX_WORKERS = 3
|
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
|
* 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;
|
* threads, each owning a disjoint row band of a `SharedArrayBuffer` framebuffer;
|
||||||
|
|
@ -41,18 +35,37 @@ export type Renderer = {
|
||||||
readonly parallel: boolean
|
readonly parallel: boolean
|
||||||
reconfigure: (config: RenderConfig) => void
|
reconfigure: (config: RenderConfig) => void
|
||||||
/** Start rendering one frame (non-blocking in the worker path). */
|
/** 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.) */
|
/** Has the dispatched frame finished? (always true single-threaded.) */
|
||||||
done: () => boolean
|
done: () => boolean
|
||||||
/** Critical-path render time of the last frame, ms (max band time / inline time). */
|
/** Critical-path render time of the last frame, ms (max band time / inline time). */
|
||||||
workMs: () => number
|
workMs: () => number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers?: boolean): Renderer {
|
type PendingFrame = {
|
||||||
const hw = (globalThis.navigator as Navigator | undefined)?.hardwareConcurrency ?? 4
|
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 workerCount = Math.max(1, Math.min(MAX_WORKERS, hw - 1))
|
||||||
const maxVis = Math.max(1, scene.chunks.length)
|
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
|
let config = initial
|
||||||
const want = forceWorkers ?? ENABLE_WORKERS
|
const want = forceWorkers ?? ENABLE_WORKERS
|
||||||
let parallel = want && canShare()
|
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 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 vp: Float32Array<ArrayBufferLike> = new Float32Array(0) // the view-projection matrix
|
||||||
let vis: Int32Array<ArrayBufferLike> = new Int32Array(0) // visible chunk indices
|
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 times: Float64Array<ArrayBufferLike> = new Float64Array(0) // per-worker band render ms
|
||||||
let lastWork = 0
|
let lastWork = 0
|
||||||
|
let generation = 0
|
||||||
|
let pending: PendingFrame | null = null
|
||||||
|
|
||||||
function setup(): void {
|
function stopWorkers(): void {
|
||||||
for (const w of workers) {
|
for (const worker of workers) {
|
||||||
w.terminate()
|
worker.terminate()
|
||||||
}
|
}
|
||||||
workers = []
|
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 width = config.internalWidth
|
||||||
const height = config.internalHeight
|
const height = config.internalHeight
|
||||||
if (parallel) {
|
if (parallel) {
|
||||||
const n = width * height
|
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)
|
const bands = splitBands(height, workerCount, SKY_STEP)
|
||||||
ctrl = new Int32Array(new SharedArrayBuffer(4 * 4))
|
ctrl = new Int32Array(
|
||||||
cam = new Float64Array(new SharedArrayBuffer(7 * 8))
|
new SharedArrayBuffer(RenderProtocol.CONTROL_LENGTH * 4),
|
||||||
vp = new Float32Array(new SharedArrayBuffer(16 * 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))
|
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))
|
times = new Float64Array(new SharedArrayBuffer(bands.length * 8))
|
||||||
try {
|
try {
|
||||||
bands.forEach((band, index) => {
|
bands.forEach((band, index) => {
|
||||||
const worker = new Worker(new URL("./render-worker.ts", import.meta.url), { type: "module" })
|
const worker = new Worker(
|
||||||
worker.addEventListener("error", () => {
|
new URL("./render-worker.ts", import.meta.url),
|
||||||
parallel = false
|
{ type: "module" },
|
||||||
})
|
)
|
||||||
worker.postMessage({
|
worker.addEventListener("error", () => disableParallel(currentGeneration))
|
||||||
colorSAB: fb.color.buffer,
|
const init: RenderWorkerInit = {
|
||||||
depthSAB: fb.depth.buffer,
|
colorSAB: shared(fb.color.buffer),
|
||||||
|
depthSAB: shared(fb.depth.buffer),
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
scene,
|
scene,
|
||||||
band,
|
band,
|
||||||
config,
|
config,
|
||||||
skyStep: SKY_STEP,
|
skyStep: SKY_STEP,
|
||||||
ctrlSAB: ctrl.buffer,
|
ctrlSAB: shared(ctrl.buffer),
|
||||||
camSAB: cam.buffer,
|
cameraSAB: shared(cam.buffer),
|
||||||
vpSAB: vp.buffer,
|
viewProjectionSAB: shared(vp.buffer),
|
||||||
visSAB: vis.buffer,
|
visibleChunkSAB: shared(vis.buffer),
|
||||||
mobSAB: mob.buffer,
|
instanceIdSAB: shared(instanceIds.buffer),
|
||||||
timesSAB: times.buffer,
|
instanceTransformSAB: shared(instanceTransforms.buffer),
|
||||||
index,
|
timesSAB: shared(times.buffer),
|
||||||
})
|
workerIndex: index,
|
||||||
|
}
|
||||||
|
worker.postMessage(init)
|
||||||
workers.push(worker)
|
workers.push(worker)
|
||||||
})
|
})
|
||||||
|
Atomics.store(ctrl, RenderProtocol.DONE, workers.length)
|
||||||
} catch {
|
} catch {
|
||||||
parallel = false
|
parallel = false
|
||||||
for (const w of workers) {
|
stopWorkers()
|
||||||
w.terminate()
|
|
||||||
}
|
|
||||||
workers = []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!parallel || workers.length === 0) {
|
if (!parallel || workers.length === 0) {
|
||||||
|
|
@ -134,44 +205,44 @@ export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers
|
||||||
config = next
|
config = next
|
||||||
setup()
|
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) {
|
if (parallel && workers.length > 0) {
|
||||||
cam[0] = camera.position.x
|
RenderProtocol.writeCamera(cam, camera, time)
|
||||||
cam[1] = camera.position.y
|
RenderProtocol.writeViewProjection(vp, viewProj)
|
||||||
cam[2] = camera.position.z
|
|
||||||
cam[3] = camera.yaw
|
|
||||||
cam[4] = camera.pitch
|
|
||||||
cam[5] = camera.fov
|
|
||||||
cam[6] = time
|
|
||||||
vp.set(viewProj)
|
|
||||||
const count = Math.min(visible.length, vis.length)
|
const count = Math.min(visible.length, vis.length)
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
vis[i] = visible[i]
|
vis[i] = visible[i]
|
||||||
}
|
}
|
||||||
const mobCount = Math.min(mobDraws.length, maxMobs)
|
const instanceCount = RenderProtocol.writeInstances(
|
||||||
for (let i = 0; i < mobCount; i++) {
|
instanceIds,
|
||||||
const d = mobDraws[i]
|
instanceTransforms,
|
||||||
const o = i * MOB_FLOATS
|
instances,
|
||||||
mob[o] = MOB_KINDS.indexOf(d.kind)
|
)
|
||||||
mob[o + 1] = d.x
|
Atomics.store(ctrl, RenderProtocol.VISIBLE_CHUNKS, count)
|
||||||
mob[o + 2] = d.y
|
Atomics.store(ctrl, RenderProtocol.VISIBLE_INSTANCES, instanceCount)
|
||||||
mob[o + 3] = d.z
|
Atomics.store(ctrl, RenderProtocol.DONE, 0)
|
||||||
mob[o + 4] = d.heading
|
Atomics.add(ctrl, RenderProtocol.FRAME, 1)
|
||||||
mob[o + 5] = d.scale
|
Atomics.notify(ctrl, RenderProtocol.FRAME, workers.length)
|
||||||
}
|
|
||||||
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)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const t0 = performance.now()
|
renderInline(pending)
|
||||||
renderBand(fb, scene, camera, viewProj, visible, mobDraws, config, SKY_STEP, time, 0, fb.height)
|
pending = null
|
||||||
lastWork = performance.now() - t0
|
|
||||||
},
|
},
|
||||||
done() {
|
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() {
|
workMs() {
|
||||||
if (parallel && workers.length > 0) {
|
if (parallel && workers.length > 0) {
|
||||||
|
|
@ -194,20 +265,33 @@ function canShare(): boolean {
|
||||||
return (
|
return (
|
||||||
typeof SharedArrayBuffer !== "undefined" &&
|
typeof SharedArrayBuffer !== "undefined" &&
|
||||||
typeof Worker !== "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
|
/** 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
|
* 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. */
|
* 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 bands: [number, number][] = []
|
||||||
const per = Math.ceil(height / count)
|
const per = Math.ceil(height / count)
|
||||||
let y = 0
|
let y = 0
|
||||||
while (y < height) {
|
while (y < height) {
|
||||||
const raw = y + per
|
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])
|
bands.push([y, y1])
|
||||||
y = y1
|
y = y1
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
docs/adr/0001-engine-owns-world-concepts.md
Normal file
3
docs/adr/0001-engine-owns-world-concepts.md
Normal 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.
|
||||||
|
|
@ -22,7 +22,7 @@ export namespace Mat4 {
|
||||||
|
|
||||||
/** Model transform T * Ry * S: uniform `scale`, then a yaw rotation about Y,
|
/** Model transform T * Ry * S: uniform `scale`, then a yaw rotation about Y,
|
||||||
* then a translation. Built directly in column-major storage (no intermediate
|
* 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`. */
|
* 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 {
|
export function compose(tx: number, ty: number, tz: number, yaw: number, scale: number): Mat4 {
|
||||||
const c = Math.cos(yaw)
|
const c = Math.cos(yaw)
|
||||||
|
|
|
||||||
29
engine/render/Chunk.ts
Normal file
29
engine/render/Chunk.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
123
engine/render/ChunkBuilder.ts
Normal file
123
engine/render/ChunkBuilder.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -115,8 +115,8 @@ export namespace Rasterizer {
|
||||||
* NEAR_W) with a single Sutherland-Hodgman pass, writing the result (0, 3, or
|
* NEAR_W) with a single Sutherland-Hodgman pass, writing the result (0, 3, or
|
||||||
* 4 verts) to `dst` and returning its vertex count.
|
* 4 verts) to `dst` and returning its vertex count.
|
||||||
*
|
*
|
||||||
* This matters even when standing inside the room: a wall to your side has
|
* Geometry intersecting the camera plane has vertices both in front of and
|
||||||
* vertices both in front of and behind the eye. Without clipping, the behind
|
* behind the eye. Without clipping, the behind
|
||||||
* vertices have w <= 0 and invert under the perspective divide, smearing the
|
* vertices have w <= 0 and invert under the perspective divide, smearing the
|
||||||
* triangle across the whole screen (and risking divide-by-zero).
|
* triangle across the whole screen (and risking divide-by-zero).
|
||||||
*/
|
*/
|
||||||
|
|
@ -201,7 +201,7 @@ export namespace Rasterizer {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Backface cull: a back-facing triangle has positive area here. Only for
|
// 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) {
|
if (cull && area > 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,8 @@ export type RenderConfig = {
|
||||||
* short draw distance and the shimmer of far geometry. It also colors pixels
|
* 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`. */
|
* no triangle covers, so the frame's clear color should match `fog.color`. */
|
||||||
fog: Fog | null
|
fog: Fog | null
|
||||||
/** Beyond this distance (world units) trees + boulders draw as cheap low-poly
|
/** Beyond this distance (world units), chunks draw their cheaper far groups
|
||||||
* impostors instead of full geometry, cutting per-triangle work in dense
|
* instead of near geometry. `Infinity` disables LOD. */
|
||||||
* views. Kept inside `fog.far` so far detail is already fog-dimmed at the
|
|
||||||
* switch; `Infinity` disables LOD. */
|
|
||||||
lodDistance: number
|
lodDistance: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
123
engine/render/RenderProtocol.ts
Normal file
123
engine/render/RenderProtocol.ts
Normal 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],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
224
engine/render/RenderScene.ts
Normal file
224
engine/render/RenderScene.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
/** Open actor behavior and representation. Concrete definitions are ordinary game
|
||||||
* its own behavior and a live transform (mobs, and later the npc / powerups) -- as
|
* objects referenced directly by instances. */
|
||||||
* opposed to a baked, static `Prop`. `State` is the per-instance runtime record the
|
export type ActorDefinition<State, World> = {
|
||||||
* behavior mutates; `World` is whatever that behavior reads (e.g. `Terrain`).
|
prototype: RenderPrototype
|
||||||
*
|
|
||||||
* 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. */
|
|
||||||
update: (state: State, dt: number, world: World) => void
|
update: (state: State, dt: number, world: World) => void
|
||||||
/** Local bounding radius (pre-scale) for the per-frame cull AABB. */
|
transform: (state: State) => RenderTransform
|
||||||
boundingRadius: number
|
collider?: (state: State, world: World) => Collider | null
|
||||||
/** Local body height (pre-scale) for the top of the stand-on collider. */
|
}
|
||||||
bodyHeight: number
|
|
||||||
|
/** 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
80
engine/scene/MeshBuilder.ts
Normal file
80
engine/scene/MeshBuilder.ts
Normal 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
38
engine/scene/Prefab.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
engine/world/CharacterController.ts
Normal file
89
engine/world/CharacterController.ts
Normal 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
116
engine/world/Collider.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
62
engine/world/CollisionWorld.ts
Normal file
62
engine/world/CollisionWorld.ts
Normal 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
123
engine/world/Level.ts
Normal 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
121
engine/world/Terrain.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
106
game/Terrain.ts
106
game/Terrain.ts
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import type { Vec3 } from "../../engine/math/Vec3"
|
import type { Vec3 } from "../../engine/math/Vec3"
|
||||||
import { STRIDE, type Mesh } from "../../engine/scene/Mesh"
|
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
|
const TAU = Math.PI * 2
|
||||||
|
|
||||||
|
|
@ -22,8 +24,35 @@ export type Boulder = {
|
||||||
* field of boulders batches into a single draw call.
|
* field of boulders batches into a single draw call.
|
||||||
*/
|
*/
|
||||||
export namespace Boulder {
|
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. */
|
/** `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 rand = rng(boulder.seed)
|
||||||
const seg = lod === "impostor" ? 4 : 5
|
const seg = lod === "impostor" ? 4 : 5
|
||||||
const rings = lod === "impostor" ? 2 : 4
|
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
|
/** 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)
|
* longitude seam (last column == first) and each pole row (one shared value)
|
||||||
* match so the mesh stays closed. */
|
* 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[][] = []
|
const grid: number[][] = []
|
||||||
for (let ir = 0; ir <= rings; ir++) {
|
for (let ir = 0; ir <= rings; ir++) {
|
||||||
const pole = ir === 0 || ir === rings
|
const pole = ir === 0 || ir === rings
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import type { Vec3 } from "../../engine/math/Vec3"
|
import type { Vec3 } from "../../engine/math/Vec3"
|
||||||
import { STRIDE, type Mesh } from "../../engine/scene/Mesh"
|
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
|
const TAU = Math.PI * 2
|
||||||
|
|
||||||
|
|
@ -17,6 +19,13 @@ export type Bush = {
|
||||||
* wound outward, so backface culling is safe. `build` appends into a shared
|
* wound outward, so backface culling is safe. `build` appends into a shared
|
||||||
* (leaf-textured) mesh. */
|
* (leaf-textured) mesh. */
|
||||||
export namespace Bush {
|
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 {
|
export function build(bush: Bush, mesh: Mesh): void {
|
||||||
const rand = rng(bush.seed)
|
const rand = rng(bush.seed)
|
||||||
// A handful of smaller overlapping lumps reads as a soft shrub; one big
|
// 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). */
|
/** 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 seg = 6
|
||||||
const rings = 4
|
const rings = 4
|
||||||
const start = mesh.verts.length / STRIDE
|
const start = mesh.verts.length / STRIDE
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
import type { Vec3 } from "../../engine/math/Vec3"
|
import type { Vec3 } from "../../engine/math/Vec3"
|
||||||
import { Mesh } from "../../engine/scene/Mesh"
|
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
|
const TAU = Math.PI * 2
|
||||||
|
|
||||||
/** Flower bloom color, indexing a region of the `flower` texture atlas. */
|
/** Concrete atlas style referenced directly by flower instances. */
|
||||||
export type FlowerColor = "white" | "red" | "yellow"
|
export type FlowerStyle = { bloomUv: [number, number] }
|
||||||
|
|
||||||
/** A single small flower: a thin crossed-quad stem plus a shallow fan of petals.
|
/** 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.
|
* Tiny, so it is drawn double-sided (no backface cull) and carries no collider.
|
||||||
* `size` is roughly its height; `seed` jitters the petals. */
|
* `size` is roughly its height; `seed` jitters the petals. */
|
||||||
export type Flower = {
|
export type Flower = {
|
||||||
position: Vec3
|
position: Vec3
|
||||||
color: FlowerColor
|
style: FlowerStyle
|
||||||
size: number
|
size: number
|
||||||
seed: number
|
seed: number
|
||||||
}
|
}
|
||||||
|
|
@ -23,16 +25,21 @@ export type Flower = {
|
||||||
* or draw call. `build` appends into one shared flower mesh.
|
* or draw call. `build` appends into one shared flower mesh.
|
||||||
*/
|
*/
|
||||||
export namespace Flower {
|
export namespace Flower {
|
||||||
/** uv center of each bloom color's atlas region (tile units). */
|
export const white: FlowerStyle = { bloomUv: [0.75, 0.25] }
|
||||||
const BLOOM_UV: Record<FlowerColor, [number, number]> = {
|
export const red: FlowerStyle = { bloomUv: [0.25, 0.75] }
|
||||||
white: [0.75, 0.25],
|
export const yellow: FlowerStyle = { bloomUv: [0.75, 0.75] }
|
||||||
red: [0.25, 0.75],
|
|
||||||
yellow: [0.75, 0.75],
|
|
||||||
}
|
|
||||||
/** uv center of the green stem region. */
|
/** uv center of the green stem region. */
|
||||||
const STEM_U = 0.25
|
const STEM_U = 0.25
|
||||||
const STEM_V = 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 {
|
export function build(flower: Flower, mesh: Mesh): void {
|
||||||
const rand = rng(flower.seed)
|
const rand = rng(flower.seed)
|
||||||
const p = flower.position
|
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, w, 0)
|
||||||
stem(mesh, p.x, p.y, p.z, bloomY, 0, w)
|
stem(mesh, p.x, p.y, p.z, bloomY, 0, w)
|
||||||
// Bloom: a shallow fan of petals, center raised a touch so it domes.
|
// 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 rad = flower.size * 0.38
|
||||||
const center = Mesh.push(mesh, p.x, bloomY + rad * 0.3, p.z, bu, bv)
|
const center = Mesh.push(mesh, p.x, bloomY + rad * 0.3, p.z, bu, bv)
|
||||||
const ring = center + 1
|
const ring = center + 1
|
||||||
const petals = 5
|
const petals = 5
|
||||||
for (let i = 0; i <= petals; i++) {
|
for (let i = 0; i <= petals; i++) {
|
||||||
const angle = (i / petals) * TAU + rand() * 0.4
|
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++) {
|
for (let i = 0; i < petals; i++) {
|
||||||
mesh.indices.push(center, ring + i, ring + i + 1)
|
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). */
|
/** 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 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 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)
|
const c = Mesh.push(mesh, x + dx, y1, z + dz, STEM_U, STEM_V)
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,14 @@
|
||||||
import type { Terrain } from "../Terrain"
|
|
||||||
import type { Vec3 } from "../../engine/math/Vec3"
|
import type { Vec3 } from "../../engine/math/Vec3"
|
||||||
import type { Mesh } from "../../engine/scene/Mesh"
|
import type { Actor } from "../../engine/scene/Actor"
|
||||||
import type { Entity } from "../../engine/scene/Actor"
|
import type { RenderTransform } from "../../engine/render/RenderScene"
|
||||||
import { frog } from "./mobs/Frog"
|
import type { BoxCollider } from "../../engine/world/Collider"
|
||||||
import { bee } from "./mobs/Bee"
|
import type { Terrain } from "../../engine/world/Terrain"
|
||||||
import { robin } from "./mobs/Robin"
|
|
||||||
|
|
||||||
/** A roaming creature drawn as a moving low-poly mesh (unlike the static baked
|
/** Runtime actor using shared roaming-creature state. Concrete definitions are
|
||||||
* world). Each kind is an `Entity` definition (geometry + behavior + bounds) living
|
* direct object references supplied by Frog, Bee, Robin, or future content. */
|
||||||
* in its own module under `mobs/`; this file just assembles them into a registry
|
export type Mob = Actor<Terrain>
|
||||||
* 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"
|
|
||||||
|
|
||||||
export type Mob = {
|
export type MobState = {
|
||||||
kind: MobKind
|
|
||||||
/** Leash anchor (where it was scattered); wandering is pulled back toward it. */
|
/** Leash anchor (where it was scattered); wandering is pulled back toward it. */
|
||||||
home: Vec3
|
home: Vec3
|
||||||
/** Live feet-center (frog/robin) / body-center (bee), advanced each frame. */
|
/** Live feet-center (frog/robin) / body-center (bee), advanced each frame. */
|
||||||
|
|
@ -39,46 +26,39 @@ export type Mob = {
|
||||||
vy: number
|
vy: number
|
||||||
/** Countdown to the next decision (frog/robin: next hop; bee: next heading change). */
|
/** Countdown to the next decision (frog/robin: next hop; bee: next heading change). */
|
||||||
timer: number
|
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). */
|
* powered-flight cruise time (>0 while gliding between perches). */
|
||||||
phase: number
|
phase: number
|
||||||
/** Frog/robin: resting on the ground vs airborne (a hop or a flight). */
|
/** Frog/robin: resting on the ground vs airborne (a hop or a flight). */
|
||||||
grounded: boolean
|
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 {
|
export namespace Mob {
|
||||||
/** The definition for a kind (geometry, behavior, bounds). */
|
export function transform(state: MobState): RenderTransform {
|
||||||
export function def(kind: MobKind): Entity<Mob, Terrain> {
|
return {
|
||||||
return DEFS[kind]
|
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 collider(
|
||||||
export function update(mob: Mob, dt: number, terrain: Terrain): void {
|
state: MobState,
|
||||||
DEFS[mob.kind].update(mob, dt, terrain)
|
radius: number,
|
||||||
}
|
height: number,
|
||||||
|
standable: boolean,
|
||||||
/** Append the canonical local-space mesh for `kind` into `mesh` (once per kind at
|
): BoxCollider {
|
||||||
* load; every instance shares it, differing only by transform). */
|
const half = radius * state.scale * 0.7
|
||||||
export function build(kind: MobKind, mesh: Mesh): void {
|
return {
|
||||||
DEFS[kind].build(mesh)
|
shape: "box",
|
||||||
}
|
minX: state.position.x - half,
|
||||||
|
maxX: state.position.x + half,
|
||||||
/** Local bounding radius (pre-scale), for building the per-frame cull AABB. */
|
minZ: state.position.z - half,
|
||||||
export function boundingRadius(kind: MobKind): number {
|
maxZ: state.position.z + half,
|
||||||
return DEFS[kind].boundingRadius
|
top: state.position.y + height * state.scale,
|
||||||
}
|
standable,
|
||||||
|
}
|
||||||
/** Local body height (pre-scale), for the top of the stand-on collider. */
|
|
||||||
export function bodyHeight(kind: MobKind): number {
|
|
||||||
return DEFS[kind].bodyHeight
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,11 @@
|
||||||
import type { Vec3 } from "../../engine/math/Vec3"
|
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
|
/** 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
|
* height and girth and adds canopy blobs / tiers. `seed` drives the per-tree random
|
||||||
* wobble so a forest doesn't look cloned. */
|
* wobble so a forest doesn't look cloned. */
|
||||||
export type Tree = {
|
export type Tree = {
|
||||||
kind: TreeKind
|
|
||||||
/** Trunk base, sitting on the ground. */
|
/** Trunk base, sitting on the ground. */
|
||||||
position: Vec3
|
position: Vec3
|
||||||
growth: number
|
growth: number
|
||||||
seed: 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,28 @@
|
||||||
import { Terrain } from "../../Terrain"
|
import { Terrain, type Terrain as Ground } from "../../../engine/world/Terrain"
|
||||||
import type { Mesh } from "../../../engine/scene/Mesh"
|
import { Mesh, type Mesh as Geometry } from "../../../engine/scene/Mesh"
|
||||||
import type { Mob } from "../Mob"
|
import type { Material } from "../../../engine/render/Material"
|
||||||
import type { Entity } from "../../../engine/scene/Actor"
|
import type { ActorDefinition } from "../../../engine/scene/Actor"
|
||||||
|
import { Mob, type MobState } from "../Mob"
|
||||||
import { ellipsoid, nextRand, ovoidZ, wanderHeading, wing } from "./mobkit"
|
import { ellipsoid, nextRand, ovoidZ, wanderHeading, wing } from "./mobkit"
|
||||||
|
|
||||||
export const bee: Entity<Mob, Terrain> = {
|
export type Bee = ActorDefinition<MobState, Ground>
|
||||||
name: "bee",
|
|
||||||
build,
|
export namespace Bee {
|
||||||
update,
|
export function create(material: Material): Bee {
|
||||||
boundingRadius: 0.5,
|
const mesh = Mesh.create()
|
||||||
bodyHeight: 0.5,
|
build(mesh)
|
||||||
|
return {
|
||||||
|
prototype: {
|
||||||
|
groups: [{ mesh, material }],
|
||||||
|
radius: 0.6,
|
||||||
|
minY: -0.5,
|
||||||
|
maxY: 1,
|
||||||
|
},
|
||||||
|
update,
|
||||||
|
transform: Mob.transform,
|
||||||
|
collider: (state) => Mob.collider(state, 0.5, 0.5, false),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEASH = 6
|
const LEASH = 6
|
||||||
|
|
@ -20,7 +33,7 @@ const HOVER = 1.1
|
||||||
const BOB_AMP = 0.18
|
const BOB_AMP = 0.18
|
||||||
const BOB_FREQ = 3
|
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
|
// 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
|
// 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.
|
// (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)
|
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.phase += dt
|
||||||
mob.timer -= dt
|
mob.timer -= dt
|
||||||
if (mob.timer <= 0) {
|
if (mob.timer <= 0) {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,30 @@
|
||||||
import { Terrain } from "../../Terrain"
|
import { Terrain, type Terrain as Ground } from "../../../engine/world/Terrain"
|
||||||
import type { Mesh } from "../../../engine/scene/Mesh"
|
import { Mesh, type Mesh as Geometry } from "../../../engine/scene/Mesh"
|
||||||
import type { Mob } from "../Mob"
|
import type { Material } from "../../../engine/render/Material"
|
||||||
import type { Entity } from "../../../engine/scene/Actor"
|
import type { ActorDefinition } from "../../../engine/scene/Actor"
|
||||||
|
import { Mob, type MobState } from "../Mob"
|
||||||
import { ellipsoid, nextRand, wanderHeading } from "./mobkit"
|
import { ellipsoid, nextRand, wanderHeading } from "./mobkit"
|
||||||
|
|
||||||
// Everything about the frog: squat, ground-bound, sits then springs a ballistic hop.
|
// Everything about the frog: squat, ground-bound, sits then springs a ballistic hop.
|
||||||
|
|
||||||
export const frog: Entity<Mob, Terrain> = {
|
export type Frog = ActorDefinition<MobState, Ground>
|
||||||
name: "frog",
|
|
||||||
build,
|
export namespace Frog {
|
||||||
update,
|
export function create(material: Material): Frog {
|
||||||
boundingRadius: 0.7,
|
const mesh = Mesh.create()
|
||||||
bodyHeight: 0.6,
|
build(mesh)
|
||||||
|
return {
|
||||||
|
prototype: {
|
||||||
|
groups: [{ mesh, material }],
|
||||||
|
radius: 0.7,
|
||||||
|
minY: -0.7,
|
||||||
|
maxY: 1.3,
|
||||||
|
},
|
||||||
|
update,
|
||||||
|
transform: Mob.transform,
|
||||||
|
collider: (state) => Mob.collider(state, 0.7, 0.6, state.grounded),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEASH = 5
|
const LEASH = 5
|
||||||
|
|
@ -21,7 +34,7 @@ const HOP_SPEED = 1.6
|
||||||
const HOP_IMPULSE = 3.2
|
const HOP_IMPULSE = 3.2
|
||||||
const GRAVITY = 14
|
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:
|
// 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.
|
// 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)
|
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)
|
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) {
|
if (mob.grounded) {
|
||||||
mob.timer -= dt
|
mob.timer -= dt
|
||||||
mob.position.y = Terrain.height(terrain, mob.position.x, mob.position.z)
|
mob.position.y = Terrain.height(terrain, mob.position.x, mob.position.z)
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,31 @@
|
||||||
import { Terrain } from "../../Terrain"
|
import { Terrain, type Terrain as Ground } from "../../../engine/world/Terrain"
|
||||||
import type { Mesh } from "../../../engine/scene/Mesh"
|
import { Mesh, type Mesh as Geometry } from "../../../engine/scene/Mesh"
|
||||||
import type { Mob } from "../Mob"
|
import type { Material } from "../../../engine/render/Material"
|
||||||
import type { Entity } from "../../../engine/scene/Actor"
|
import type { ActorDefinition } from "../../../engine/scene/Actor"
|
||||||
|
import { Mob, type MobState } from "../Mob"
|
||||||
import { ellipsoid, nextRand, wanderHeading } from "./mobkit"
|
import { ellipsoid, nextRand, wanderHeading } from "./mobkit"
|
||||||
|
|
||||||
// Everything about the robin: round red-breasted bird that mostly hops like a frog
|
// 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.
|
// but now and then takes a short powered flight to a new perch.
|
||||||
|
|
||||||
export const robin: Entity<Mob, Terrain> = {
|
export type Robin = ActorDefinition<MobState, Ground>
|
||||||
name: "robin",
|
|
||||||
build,
|
export namespace Robin {
|
||||||
update,
|
export function create(material: Material): Robin {
|
||||||
boundingRadius: 0.45,
|
const mesh = Mesh.create()
|
||||||
bodyHeight: 0.55,
|
build(mesh)
|
||||||
|
return {
|
||||||
|
prototype: {
|
||||||
|
groups: [{ mesh, material }],
|
||||||
|
radius: 0.5,
|
||||||
|
minY: -0.5,
|
||||||
|
maxY: 1,
|
||||||
|
},
|
||||||
|
update,
|
||||||
|
transform: Mob.transform,
|
||||||
|
collider: (state) => Mob.collider(state, 0.45, 0.55, state.grounded),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEASH = 6
|
const LEASH = 6
|
||||||
|
|
@ -27,7 +40,7 @@ const FLY_IMPULSE = 3.5
|
||||||
const CRUISE = 0.8
|
const CRUISE = 0.8
|
||||||
const GRAVITY = 14
|
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
|
// 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.
|
// 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).
|
// 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)
|
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) {
|
if (mob.grounded) {
|
||||||
mob.timer -= dt
|
mob.timer -= dt
|
||||||
mob.position.y = Terrain.height(terrain, mob.position.x, mob.position.z)
|
mob.position.y = Terrain.height(terrain, mob.position.x, mob.position.z)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import type { Mob } from "../Mob"
|
import type { MobState } from "../Mob"
|
||||||
import { STRIDE, type Mesh } from "../../../engine/scene/Mesh"
|
import { STRIDE, type Mesh } from "../../../engine/scene/Mesh"
|
||||||
|
|
||||||
// Shared building blocks for the per-kind mob definitions (Frog/Bee/Robin): the
|
// Shared building blocks for concrete mob definitions: faceted geometry primitives
|
||||||
// faceted geometry primitives and the deterministic wander helpers. Kept in its own
|
// and deterministic wander helpers.
|
||||||
// module (no runtime import of `Mob`, only its type) so the per-kind files and the
|
|
||||||
// `Mob` registry don't form an import cycle.
|
|
||||||
|
|
||||||
export const TAU = Math.PI * 2
|
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
|
/** 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
|
* so the mob never drifts off into the peaks (`jitter` = the random cone half-width
|
||||||
* in radians layered on top of the homeward bearing). */
|
* 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 dx = mob.home.x - mob.position.x
|
||||||
const dz = mob.home.z - mob.position.z
|
const dz = mob.home.z - mob.position.z
|
||||||
if (dx * dx + dz * dz > leash * leash) {
|
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
|
/** mulberry32 step over the mob's own `seed` (mutated), so a mob's motion is
|
||||||
* deterministic and needs no external RNG object to clone. */
|
* 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
|
const a = (mob.seed + 0x6D2B79F5) | 0
|
||||||
mob.seed = a
|
mob.seed = a
|
||||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||||
|
|
@ -33,7 +35,7 @@ export function nextRand(mob: Mob): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Geometry primitives --------------------------------------------------
|
// --- 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.
|
// these only need to place faceted, flat-shaded surfaces.
|
||||||
|
|
||||||
/** A UV-rected ellipsoid (pole on Y), faceted like the boulders. */
|
/** 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++) {
|
for (let is = 0; is <= seg; is++) {
|
||||||
const theta = (is / seg) * TAU
|
const theta = (is / seg) * TAU
|
||||||
const u = u0 + (u1 - u0) * (is / seg)
|
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)
|
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. */
|
/** 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
|
const base = mesh.verts.length / STRIDE
|
||||||
mesh.verts.push(
|
mesh.verts.push(
|
||||||
side * 0.06, 0.12, 0.14, u0, v0,
|
side * 0.06,
|
||||||
side * 0.42, 0.24, 0.1, u1, v0,
|
0.12,
|
||||||
side * 0.42, 0.24, -0.12, u1, v1,
|
0.14,
|
||||||
side * 0.06, 0.12, -0.1, u0, v1,
|
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)
|
mesh.indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,51 @@
|
||||||
import { Vec3 } from "../../../engine/math/Vec3"
|
import { Vec3 } from "../../../engine/math/Vec3"
|
||||||
import type { Mesh } from "../../../engine/scene/Mesh"
|
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"
|
import { blob, lerp, limb, TAU, rng } from "./treekit"
|
||||||
|
|
||||||
// Silver birch: tall, slender, near-straight trunk under an airy, high, slightly
|
// Silver birch: tall, slender, near-straight trunk under an airy, high, slightly
|
||||||
// drooping canopy -- a lean silhouette between the broad oak and conical spruce.
|
// 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).
|
// 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 base = tree.position
|
||||||
const g = tree.growth
|
const g = tree.growth
|
||||||
const rand = rng(tree.seed)
|
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 canopyY = base.y + h * 0.75
|
||||||
const blobR = h * 0.22
|
const blobR = h * 0.22
|
||||||
if (lod === "impostor") {
|
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)
|
blob(leaves, { x: base.x, y: canopyY, z: base.z }, blobR * 1.1, rand, 4, 2)
|
||||||
return
|
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
|
const spread = h * 0.22
|
||||||
// Sparse small blobs clustered high, biased downward so the crown droops.
|
// 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())
|
const branches = 2 + Math.round(rand())
|
||||||
for (let i = 0; i < branches; i++) {
|
for (let i = 0; i < branches; i++) {
|
||||||
const angle = rand() * TAU
|
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 start = { x: base.x, y: base.y + h * 0.7, z: base.z }
|
||||||
const end = Vec3.add(start, Vec3.scale(dir, h * 0.22))
|
const end = Vec3.add(start, Vec3.scale(dir, h * 0.22))
|
||||||
limb(trunk, start, end, rTrunk * 0.4, rTrunk * 0.15, 4)
|
limb(trunk, start, end, rTrunk * 0.4, rTrunk * 0.15, 4)
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,48 @@
|
||||||
import { Vec3 } from "../../../engine/math/Vec3"
|
import { Vec3 } from "../../../engine/math/Vec3"
|
||||||
import type { Mesh } from "../../../engine/scene/Mesh"
|
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"
|
import { blob, lerp, limb, TAU, rng } from "./treekit"
|
||||||
|
|
||||||
// Oak: short tapered trunk, a couple of branches, a broad cluster of rounded canopy
|
// 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.
|
// 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 base = tree.position
|
||||||
const g = tree.growth
|
const g = tree.growth
|
||||||
const rand = rng(tree.seed)
|
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
|
const blobR = h * 0.3
|
||||||
if (lod === "impostor") {
|
if (lod === "impostor") {
|
||||||
// One low-poly blob on a stubby trunk -- reads as an oak at distance.
|
// 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)
|
blob(leaves, { x: base.x, y: canopyY, z: base.z }, blobR * 1.15, rand, 4, 2)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -43,7 +84,11 @@ function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"):
|
||||||
const branches = 2 + Math.round(rand())
|
const branches = 2 + Math.round(rand())
|
||||||
for (let i = 0; i < branches; i++) {
|
for (let i = 0; i < branches; i++) {
|
||||||
const angle = rand() * TAU
|
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 start = { x: base.x, y: base.y + h * 0.42, z: base.z }
|
||||||
const end = Vec3.add(start, Vec3.scale(dir, h * 0.3))
|
const end = Vec3.add(start, Vec3.scale(dir, h * 0.3))
|
||||||
limb(trunk, start, end, rTrunk * 0.4, rTrunk * 0.2, 4)
|
limb(trunk, start, end, rTrunk * 0.4, rTrunk * 0.2, 4)
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,63 @@
|
||||||
import type { Mesh } from "../../../engine/scene/Mesh"
|
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"
|
import { cone, lerp, limb, rng } from "./treekit"
|
||||||
|
|
||||||
// Spruce: tall thin trunk under stacked cones that narrow to a point (tiered, taller
|
// Spruce: tall thin trunk under stacked cones that narrow to a point (tiered, taller
|
||||||
// than wide). Trunk = brown bark, foliage = spruce needle.
|
// 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 base = tree.position
|
||||||
const g = tree.growth
|
const g = tree.growth
|
||||||
const rand = rng(tree.seed)
|
const rand = rng(tree.seed)
|
||||||
const h = lerp(0.6, 9, g)
|
const h = lerp(0.6, 9, g)
|
||||||
const rTrunk = lerp(0.03, 0.2, g)
|
const rTrunk = lerp(0.03, 0.2, g)
|
||||||
const impostor = lod === "impostor"
|
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
|
// 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).
|
// impostor keeps the first two tiers at low sides (same seed => aligned).
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { Vec3 } from "../../../engine/math/Vec3"
|
import { Vec3 } from "../../../engine/math/Vec3"
|
||||||
import { STRIDE, type Mesh } from "../../../engine/scene/Mesh"
|
import { STRIDE, type Mesh } from "../../../engine/scene/Mesh"
|
||||||
|
|
||||||
// Shared faceted-geometry primitives + the per-tree RNG, used by the species
|
// Shared faceted-geometry primitives + the per-tree RNG used by concrete tree
|
||||||
// modules (Oak/Spruce/Birch). Kept separate so a species and the `Tree` registry
|
// prefab modules.
|
||||||
// don't form an import cycle.
|
|
||||||
|
|
||||||
export const TAU = Math.PI * 2
|
export const TAU = Math.PI * 2
|
||||||
|
|
||||||
|
|
|
||||||
794
game/level.ts
794
game/level.ts
|
|
@ -1,51 +1,37 @@
|
||||||
import { Color } from "../engine/render/Color"
|
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 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 { Boulder } from "./actors/Boulder"
|
||||||
import { Bush } from "./actors/Bush"
|
import { Bush } from "./actors/Bush"
|
||||||
import { Flower, type FlowerColor } from "./actors/Flower"
|
import { Flower, type FlowerStyle } from "./actors/Flower"
|
||||||
import type { Mob, MobKind } from "./actors/Mob"
|
import type { Mob, MobState } from "./actors/Mob"
|
||||||
import { Terrain } from "./Terrain"
|
import type { Tree } from "./actors/Tree"
|
||||||
import { 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"
|
import type { Textures } from "./textures"
|
||||||
|
|
||||||
type Corner = [number, number, number]
|
type Materials = {
|
||||||
|
floor: Material
|
||||||
/** 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 = {
|
|
||||||
grass: Material
|
grass: Material
|
||||||
bark: Material
|
bark: Material
|
||||||
birch: Material
|
birch: Material
|
||||||
|
|
@ -53,50 +39,35 @@ type ChunkMaterials = {
|
||||||
needle: Material
|
needle: Material
|
||||||
rock: Material
|
rock: Material
|
||||||
flower: 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 WeightedTree = {
|
||||||
type MatKey = keyof ChunkMaterials
|
definition: PrefabDefinition<Tree>
|
||||||
|
weight: number
|
||||||
|
}
|
||||||
|
|
||||||
/** The fixed order draw groups are emitted in (grass first, flowers -- double-sided
|
type MobSpawn = {
|
||||||
* -- last), so the per-chunk draw sequence is deterministic and matches the pre-
|
definition: ActorDefinition<MobState, Ground>
|
||||||
* registry order. Every material key must appear here. */
|
count: number
|
||||||
const MAT_ORDER: MatKey[] = ["grass", "rock", "bark", "birch", "leaf", "needle", "flower"]
|
minScale: number
|
||||||
|
maxScale: number
|
||||||
/** The playground: a flat-floored room dropped into the center of a big open
|
grounded: boolean
|
||||||
* landscape. The room (floor/walls/crate) is small and always drawn; the
|
phase: (random: () => number) => number
|
||||||
* 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ARENA = 12
|
const ARENA = 12
|
||||||
const WALL_HEIGHT = 4
|
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 WALL_THICKNESS = 1.5
|
||||||
const CRATE = { x: -2, z: -2, half: 1, height: 1 }
|
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
|
const FLOOR_LIFT = 0.02
|
||||||
|
|
||||||
/** The world around the room: a flat clearing the size of the room (`inner`),
|
const TERRAIN_CONFIG: RollingTerrainConfig = {
|
||||||
* 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 = {
|
|
||||||
inner: ARENA,
|
inner: ARENA,
|
||||||
outer: ARENA * 10,
|
outer: ARENA * 10,
|
||||||
blend: 12,
|
blend: 12,
|
||||||
|
|
@ -106,52 +77,30 @@ const TERRAIN: Terrain = {
|
||||||
peakFrequency: 0.05,
|
peakFrequency: 0.05,
|
||||||
peakStart: 0.45,
|
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_COUNT = 50
|
||||||
const TREE_SEED = 0x5EED
|
const TREE_SEED = 0x5EED
|
||||||
const TREE_REACH = 1
|
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_COUNT = 50
|
||||||
const BOULDER_SEED = 0xB0142
|
const BOULDER_SEED = 0xB0142
|
||||||
const BOULDER_REACH = 1
|
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_COUNT = 50
|
||||||
const BUSH_SEED = 0xB554
|
const BUSH_SEED = 0xB554
|
||||||
const BUSH_REACH = 1
|
const BUSH_REACH = 1
|
||||||
const FLOWER_COUNT = 50
|
const FLOWER_COUNT = 50
|
||||||
const FLOWER_SEED = 0xF10E
|
const FLOWER_SEED = 0xF10E
|
||||||
const FLOWER_REACH = 0.3
|
const FLOWER_REACH = 0.3
|
||||||
const FLOWER_COLORS: FlowerColor[] = ["white", "red", "yellow"]
|
const FLOWER_STYLES: FlowerStyle[] = [Flower.white, Flower.red, Flower.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 FROG_COUNT = 20
|
const FROG_COUNT = 20
|
||||||
const BEE_COUNT = 20
|
const BEE_COUNT = 20
|
||||||
const ROBIN_COUNT = 20
|
const ROBIN_COUNT = 20
|
||||||
const MOB_SEED = 0x30B
|
const MOB_SEED = 0x30B
|
||||||
const MOB_REACH = 1
|
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 CHUNK_GRID = 12
|
||||||
const TERRAIN_SUBDIV = 5
|
const TERRAIN_SUBDIV = 5
|
||||||
const GROUND_UV = 0.25
|
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 = {
|
export const basicCumulus: CloudLayer = {
|
||||||
kind: "basic",
|
kind: "basic",
|
||||||
color: Color.rgb(248, 250, 255),
|
color: Color.rgb(248, 250, 255),
|
||||||
|
|
@ -172,33 +121,63 @@ export const fancyCumulus: CloudLayer = {
|
||||||
relief: 7,
|
relief: 7,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildLevel(textures: Textures): Level {
|
/** Concrete playground data assembled through engine-owned level mechanisms. */
|
||||||
// Flat room floor, lifted a hair above the terrain's clearing (y 0). The
|
export function buildLevel(textures: Textures): RuntimeLevel<Ground> {
|
||||||
// outdoor grid's cells straddle the room boundary and lap under the floor's
|
const materials = createMaterials(textures)
|
||||||
// edges; this small z-bias keeps the flat stone floor winning the depth test
|
const floor = Mesh.create()
|
||||||
// there instead of z-fighting the grass. The step is invisible at the doorway.
|
MeshBuilder.quad(
|
||||||
const floor = mesh()
|
floor,
|
||||||
const fy = FLOOR_LIFT
|
[-ARENA, FLOOR_LIFT, -ARENA],
|
||||||
quad(floor, [-ARENA, fy, -ARENA], [ARENA, fy, -ARENA], [ARENA, fy, ARENA], [-ARENA, fy, ARENA], 12, 12)
|
[ARENA, FLOOR_LIFT, -ARENA],
|
||||||
|
[ARENA, FLOOR_LIFT, ARENA],
|
||||||
|
[-ARENA, FLOOR_LIFT, ARENA],
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
)
|
||||||
|
|
||||||
const walls = mesh()
|
const walls = Mesh.create()
|
||||||
const h = WALL_HEIGHT
|
const thickness = WALL_THICKNESS
|
||||||
const t = WALL_THICKNESS
|
MeshBuilder.slab(
|
||||||
// Three thick perimeter walls, outer faces flush with the room edge; the north
|
walls,
|
||||||
// (-Z) side is left open onto the world. No ceiling, so the sky shows above.
|
-ARENA,
|
||||||
slab(walls, -ARENA, ARENA, ARENA - t, ARENA, 0, h, 0.5) // south (+Z)
|
ARENA,
|
||||||
slab(walls, ARENA - t, ARENA, -ARENA, ARENA - t, 0, h, 0.5) // east (+X)
|
ARENA - thickness,
|
||||||
slab(walls, -ARENA, -ARENA + t, -ARENA, ARENA - t, 0, h, 0.5) // west (-X)
|
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.create()
|
||||||
const crate = mesh()
|
MeshBuilder.box(crate, CRATE.x, CRATE.z, CRATE.half, 0, CRATE.height)
|
||||||
box(crate, CRATE.x, CRATE.z, CRATE.half, 0, CRATE.height)
|
|
||||||
|
|
||||||
const colliders: Aabb[] = [
|
const npcPosition = { x: 2, y: 0, z: -1 }
|
||||||
wall(-ARENA, ARENA, ARENA - t, ARENA),
|
const staticColliders: Collider[] = [
|
||||||
wall(ARENA - t, ARENA, -ARENA, ARENA - t),
|
wall(-ARENA, ARENA, ARENA - thickness, ARENA),
|
||||||
wall(-ARENA, -ARENA + t, -ARENA, ARENA - t),
|
wall(ARENA - thickness, ARENA, -ARENA, ARENA - thickness),
|
||||||
|
wall(-ARENA, -ARENA + thickness, -ARENA, ARENA - thickness),
|
||||||
{
|
{
|
||||||
|
shape: "box",
|
||||||
minX: CRATE.x - CRATE.half,
|
minX: CRATE.x - CRATE.half,
|
||||||
maxX: CRATE.x + CRATE.half,
|
maxX: CRATE.x + CRATE.half,
|
||||||
minZ: CRATE.z - CRATE.half,
|
minZ: CRATE.z - CRATE.half,
|
||||||
|
|
@ -206,8 +185,94 @@ export function buildLevel(textures: Textures): Level {
|
||||||
top: CRATE.height,
|
top: CRATE.height,
|
||||||
standable: true,
|
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 = {
|
const sky: SkyConfig = {
|
||||||
zenith: Color.rgb(58, 108, 196),
|
zenith: Color.rgb(58, 108, 196),
|
||||||
horizon: Color.rgb(178, 198, 226),
|
horizon: Color.rgb(178, 198, 226),
|
||||||
|
|
@ -218,12 +283,31 @@ export function buildLevel(textures: Textures): Level {
|
||||||
skybox: { texture: textures.skybox },
|
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 +
|
function createMaterials(textures: Textures): Materials {
|
||||||
// flowers). Solid surfaces backface-cull; flowers are double-sided. Shared by
|
return {
|
||||||
// every chunk, so cloning to a worker dedups them.
|
floor: { texture: textures.floor, cull: false },
|
||||||
const materials: ChunkMaterials = {
|
|
||||||
grass: { texture: textures.grass, cull: true },
|
grass: { texture: textures.grass, cull: true },
|
||||||
bark: { texture: textures.bark, cull: true },
|
bark: { texture: textures.bark, cull: true },
|
||||||
birch: { texture: textures.birch, cull: true },
|
birch: { texture: textures.birch, cull: true },
|
||||||
|
|
@ -231,319 +315,219 @@ export function buildLevel(textures: Textures): Level {
|
||||||
needle: { texture: textures.needle, cull: true },
|
needle: { texture: textures.needle, cull: true },
|
||||||
rock: { texture: textures.rock, cull: true },
|
rock: { texture: textures.rock, cull: true },
|
||||||
flower: { texture: textures.flower, cull: false },
|
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
|
function placeTrees(definitions: WeightedTree[]): PlacedPrefab[] {
|
||||||
* chunks. Each prop lands in the cell holding its base; the cell's bounds are
|
const random = mulberry(TREE_SEED)
|
||||||
* grown to the real geometry so overhanging canopies never get culled early.
|
const maxDistance = TERRAIN_CONFIG.outer * TREE_REACH
|
||||||
* Bushes share the oak leaf mesh; flowers get their own (double-sided) mesh. */
|
const trees: PlacedPrefab[] = []
|
||||||
function buildChunks(m: ChunkMaterials, trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers: Flower[]): Chunk[] {
|
for (
|
||||||
const cell = (TERRAIN.outer * 2) / CHUNK_GRID
|
let guard = 0;
|
||||||
const chunks: Chunk[] = []
|
trees.length < TREE_COUNT && guard < TREE_COUNT * 20;
|
||||||
for (let ci = 0; ci < CHUNK_GRID; ci++) {
|
guard++
|
||||||
const x0 = -TERRAIN.outer + ci * cell
|
) {
|
||||||
const x1 = x0 + cell
|
const point = scatterPoint(random, maxDistance, 5, 3)
|
||||||
for (let cj = 0; cj < CHUNK_GRID; cj++) {
|
if (point === null) {
|
||||||
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) {
|
|
||||||
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
|
continue
|
||||||
}
|
}
|
||||||
const roll = rand()
|
const definition = weightedTree(definitions, random())
|
||||||
const kind = roll < 0.4 ? "oak" : roll < 0.72 ? "spruce" : "birch"
|
const tree: Tree = {
|
||||||
const growth = 0.08 + rand() * 0.92
|
position: {
|
||||||
const position = { x, y: Terrain.height(TERRAIN, x, z), z }
|
x: point.x,
|
||||||
trees.push({ kind, position, growth, seed: (rand() * 0xFFFFFFFF) | 0 })
|
y: TERRAIN.heightAt(point.x, point.z),
|
||||||
// Saplings are passable; grown trunks block. Square footprint, non-standable.
|
z: point.z,
|
||||||
if (growth > 0.35) {
|
},
|
||||||
const r = growth * (kind === "oak" ? 0.3 : 0.2) + 0.15
|
growth: 0.08 + random() * 0.92,
|
||||||
colliders.push({ minX: x - r, maxX: x + r, minZ: z - r, maxZ: z + r, top: position.y + 3, standable: false })
|
seed: (random() * 0xFFFFFFFF) | 0,
|
||||||
}
|
}
|
||||||
|
trees.push(Prefab.place(definition, tree))
|
||||||
}
|
}
|
||||||
return trees
|
return trees
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scatter `BOULDER_COUNT` boulders across the terrain, sizes biased toward
|
function placeBoulders(definition: PrefabDefinition<Boulder>): PlacedPrefab[] {
|
||||||
* small. Each sits on the ground; big ones drop a blocking collider so you
|
const random = mulberry(BOULDER_SEED)
|
||||||
* can't walk through them (little rocks stay passable). */
|
const maxDistance = TERRAIN_CONFIG.outer * BOULDER_REACH
|
||||||
function placeBoulders(colliders: Aabb[]): Boulder[] {
|
const boulders: PlacedPrefab[] = []
|
||||||
const rand = mulberry(BOULDER_SEED)
|
for (
|
||||||
const maxDist = TERRAIN.outer * BOULDER_REACH
|
let guard = 0;
|
||||||
const boulders: Boulder[] = []
|
boulders.length < BOULDER_COUNT && guard < BOULDER_COUNT * 20;
|
||||||
for (let guard = 0; boulders.length < BOULDER_COUNT && guard < BOULDER_COUNT * 20; guard++) {
|
guard++
|
||||||
const angle = rand() * Math.PI * 2
|
) {
|
||||||
const dist = ARENA + 4 + rand() * (maxDist - ARENA - 4)
|
const point = scatterPoint(random, maxDistance, 4, 2)
|
||||||
const x = Math.cos(angle) * dist
|
if (point === null) {
|
||||||
const z = Math.sin(angle) * dist
|
|
||||||
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 2) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Square the roll so most rocks are small, a few are big.
|
boulders.push(
|
||||||
const radius = 0.35 + rand() * rand() * 2.2
|
Prefab.place(definition, {
|
||||||
const position = { x, y: Terrain.height(TERRAIN, x, z), z }
|
position: {
|
||||||
boulders.push({ position, radius, seed: (rand() * 0xFFFFFFFF) | 0 })
|
x: point.x,
|
||||||
if (radius > 0.7) {
|
y: TERRAIN.heightAt(point.x, point.z),
|
||||||
colliders.push({ minX: x - radius, maxX: x + radius, minZ: z - radius, maxZ: z + radius, top: position.y + radius * 0.7, standable: false })
|
z: point.z,
|
||||||
}
|
},
|
||||||
|
radius: 0.35 + random() * random() * 2.2,
|
||||||
|
seed: (random() * 0xFFFFFFFF) | 0,
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return boulders
|
return boulders
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scatter bushes on the grass near the play area (no colliders -- walk through). */
|
function placeBushes(definition: PrefabDefinition<Bush>): PlacedPrefab[] {
|
||||||
function placeBushes(): Bush[] {
|
const random = mulberry(BUSH_SEED)
|
||||||
const rand = mulberry(BUSH_SEED)
|
const maxDistance = TERRAIN_CONFIG.outer * BUSH_REACH
|
||||||
const maxDist = TERRAIN.outer * BUSH_REACH
|
const bushes: PlacedPrefab[] = []
|
||||||
const bushes: Bush[] = []
|
for (
|
||||||
for (let guard = 0; bushes.length < BUSH_COUNT && guard < BUSH_COUNT * 20; guard++) {
|
let guard = 0;
|
||||||
const angle = rand() * Math.PI * 2
|
bushes.length < BUSH_COUNT && guard < BUSH_COUNT * 20;
|
||||||
const dist = ARENA + 3 + rand() * (maxDist - ARENA - 3)
|
guard++
|
||||||
const x = Math.cos(angle) * dist
|
) {
|
||||||
const z = Math.sin(angle) * dist
|
const point = scatterPoint(random, maxDistance, 3, 2)
|
||||||
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 2) {
|
if (point === null) {
|
||||||
continue
|
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
|
return bushes
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scatter small flowers on the grass near the play area, colors rolled. */
|
function placeFlowers(definition: PrefabDefinition<Flower>): PlacedPrefab[] {
|
||||||
function placeFlowers(): Flower[] {
|
const random = mulberry(FLOWER_SEED)
|
||||||
const rand = mulberry(FLOWER_SEED)
|
const maxDistance = TERRAIN_CONFIG.outer * FLOWER_REACH
|
||||||
const maxDist = TERRAIN.outer * FLOWER_REACH
|
const flowers: PlacedPrefab[] = []
|
||||||
const flowers: Flower[] = []
|
for (
|
||||||
for (let guard = 0; flowers.length < FLOWER_COUNT && guard < FLOWER_COUNT * 20; guard++) {
|
let guard = 0;
|
||||||
const angle = rand() * Math.PI * 2
|
flowers.length < FLOWER_COUNT && guard < FLOWER_COUNT * 20;
|
||||||
const dist = ARENA + 2 + rand() * (maxDist - ARENA - 2)
|
guard++
|
||||||
const x = Math.cos(angle) * dist
|
) {
|
||||||
const z = Math.sin(angle) * dist
|
const point = scatterPoint(random, maxDistance, 2, 1)
|
||||||
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 1) {
|
if (point === null) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const color = FLOWER_COLORS[(rand() * FLOWER_COLORS.length) | 0]
|
flowers.push(
|
||||||
flowers.push({ position: { x, y: Terrain.height(TERRAIN, x, z), z }, color, size: 0.28 + rand() * 0.22, seed: (rand() * 0xFFFFFFFF) | 0 })
|
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
|
return flowers
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scatter frogs, bees + robins across the grass (like the boulders), each at its
|
function placeMobs(spawns: MobSpawn[]): Mob[] {
|
||||||
* home anchor with a random heading and size. No colliders here -- mobs move, so
|
const random = mulberry(MOB_SEED)
|
||||||
* their block/stand-on AABBs are rebuilt per frame in `main`. */
|
const maxDistance = TERRAIN_CONFIG.outer * MOB_REACH
|
||||||
function placeMobs(): Mob[] {
|
|
||||||
const rand = mulberry(MOB_SEED)
|
|
||||||
const maxDist = TERRAIN.outer * MOB_REACH
|
|
||||||
const mobs: Mob[] = []
|
const mobs: Mob[] = []
|
||||||
const total = FROG_COUNT + BEE_COUNT + ROBIN_COUNT
|
for (const spawn of spawns) {
|
||||||
for (let guard = 0; mobs.length < total && guard < total * 20; guard++) {
|
let placed = 0
|
||||||
const angle = rand() * Math.PI * 2
|
for (
|
||||||
const dist = ARENA + 3 + rand() * (maxDist - ARENA - 3)
|
let guard = 0;
|
||||||
const x = Math.cos(angle) * dist
|
placed < spawn.count && guard < spawn.count * 20;
|
||||||
const z = Math.sin(angle) * dist
|
guard++
|
||||||
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 2) {
|
) {
|
||||||
continue
|
const point = scatterPoint(random, maxDistance, 3, 2)
|
||||||
|
if (point === null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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: (random() * 0xFFFFFFFF) | 0,
|
||||||
|
vx: 0,
|
||||||
|
vz: 0,
|
||||||
|
vy: 0,
|
||||||
|
timer: random() * 1.5,
|
||||||
|
phase: spawn.phase(random),
|
||||||
|
grounded: spawn.grounded,
|
||||||
|
}
|
||||||
|
mobs.push(Actor.create(spawn.definition, state))
|
||||||
|
placed++
|
||||||
}
|
}
|
||||||
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,
|
|
||||||
scale,
|
|
||||||
seed: (rand() * 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",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return mobs
|
return mobs
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deterministic 0..1 generator (mulberry32) for tree placement. */
|
function scatterPoint(
|
||||||
function mulberry(seed: number): () => number {
|
random: () => number,
|
||||||
let a = seed >>> 0
|
maxDistance: number,
|
||||||
return () => {
|
clearance: number,
|
||||||
a = (a + 0x6D2B79F5) | 0
|
flatMargin: number,
|
||||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
): { x: number; z: number } | null {
|
||||||
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t)
|
const angle = random() * Math.PI * 2
|
||||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
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 {
|
function mulberry(seed: number): () => number {
|
||||||
return { verts: [], indices: [] }
|
let state = seed >>> 0
|
||||||
}
|
return () => {
|
||||||
|
state = (state + 0x6D2B79F5) | 0
|
||||||
/** A perimeter wall collider: blocks from the sides, and `standable` so you can
|
let value = Math.imul(state ^ (state >>> 15), 1 | state)
|
||||||
* jump up and land on its top (given enough JUMP_SPEED to clear WALL_HEIGHT). */
|
value ^= value + Math.imul(value ^ (value >>> 7), 61 | value)
|
||||||
function wall(minX: number, maxX: number, minZ: number, maxZ: number): Aabb {
|
return ((value ^ (value >>> 14)) >>> 0) / 4294967296
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
181
game/player.ts
181
game/player.ts
|
|
@ -1,169 +1,32 @@
|
||||||
import { Terrain } from "./Terrain"
|
import type {
|
||||||
import type { Vec3 } from "../engine/math/Vec3"
|
Character,
|
||||||
import type { Aabb, Level } from "./level"
|
CharacterConfig,
|
||||||
|
} from "../engine/world/CharacterController"
|
||||||
|
|
||||||
/** The player as a vertical cylinder. `position` is at the feet; the camera
|
export type Player = Character & {
|
||||||
* eye sits EYE_HEIGHT above it. */
|
|
||||||
export type Player = {
|
|
||||||
position: Vec3
|
|
||||||
yaw: number
|
|
||||||
pitch: number
|
pitch: number
|
||||||
velocityY: number
|
|
||||||
onGround: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EYE_HEIGHT = 1.6
|
/** Concrete player tuning. Movement and collision behavior live in engine. */
|
||||||
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
|
|
||||||
|
|
||||||
export namespace Player {
|
export namespace Player {
|
||||||
/** Advance the player one frame: jump, horizontal move + collision, gravity. */
|
export const actorCollisionRange = 3
|
||||||
export function update(player: Player, keys: Set<string>, dt: number, level: Level): void {
|
|
||||||
if (keys.has("Space") && player.onGround) {
|
export const config: CharacterConfig = {
|
||||||
player.velocityY = JUMP_SPEED
|
radius: 0.35,
|
||||||
player.onGround = false
|
speed: 6,
|
||||||
}
|
runMultiplier: 2,
|
||||||
// Move + collide in small substeps: collision is discrete (move, then push
|
gravity: 22,
|
||||||
// out), so a single big running step could otherwise skip clean through a
|
jumpSpeed: 14,
|
||||||
// wall. Substepping keeps each advance short enough to always hit it.
|
eyeHeight: 1.6,
|
||||||
const steps = moveSubsteps(keys, dt)
|
|
||||||
for (let i = 0; i < steps; i++) {
|
|
||||||
moveHorizontal(player, keys, dt / steps)
|
|
||||||
collide(player, level)
|
|
||||||
}
|
|
||||||
fall(player, dt, level)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run-speed factor for the frame: RUN_MULTIPLIER while Shift is held, else 1. */
|
export function create(): Player {
|
||||||
function runFactor(keys: Set<string>): number {
|
return {
|
||||||
return keys.has("ShiftLeft") || keys.has("ShiftRight") ? RUN_MULTIPLIER : 1
|
position: { x: 0, y: 0, z: 8 },
|
||||||
}
|
yaw: 0,
|
||||||
|
pitch: 0,
|
||||||
/** Number of move+collide substeps so each advances at most ~RADIUS, keeping
|
velocityY: 0,
|
||||||
* the player from tunneling walls however fast they run. */
|
onGround: true,
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -2,8 +2,11 @@
|
||||||
"folders": [
|
"folders": [
|
||||||
{
|
{
|
||||||
"name": "meat",
|
"name": "meat",
|
||||||
"path": ".",
|
"path": "."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "../meat.project"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"settings": {
|
"settings": {
|
||||||
"oxc.path.oxfmt": "node_modules/.bin/oxfmt",
|
"oxc.path.oxfmt": "node_modules/.bin/oxfmt",
|
||||||
|
|
|
||||||
38
tests/actors.test.ts
Normal file
38
tests/actors.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { readdirSync, readFileSync, statSync } from "node:fs"
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import path from "node:path"
|
||||||
|
|
||||||
// The engine is the reusable, content-agnostic layer: it must import nothing from
|
// 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
|
// 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[] {
|
function tsFiles(dir: string): string[] {
|
||||||
const out: string[] = []
|
const out: string[] = []
|
||||||
for (const name of readdirSync(dir)) {
|
for (const name of readdirSync(dir)) {
|
||||||
const p = join(dir, name)
|
const p = path.join(dir, name)
|
||||||
if (statSync(p).isDirectory()) {
|
if (statSync(p).isDirectory()) {
|
||||||
out.push(...tsFiles(p))
|
out.push(...tsFiles(p))
|
||||||
} else if (p.endsWith(".ts")) {
|
} else if (p.endsWith(".ts")) {
|
||||||
|
|
@ -19,14 +19,42 @@ function tsFiles(dir: string): string[] {
|
||||||
return out
|
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", () => {
|
test("engine imports nothing from game or app", () => {
|
||||||
const engineDir = new URL("../engine", import.meta.url).pathname
|
const engineDir = path.join(repositoryRoot(), "engine")
|
||||||
const offenders = tsFiles(engineDir).filter((f) => /from\s+["'][^"']*\/(?:game|app)\//.test(readFileSync(f, "utf8")))
|
const offenders = tsFiles(engineDir).filter((file) => importsLayer(readFileSync(file, "utf8"), "game|app"))
|
||||||
expect(offenders).toEqual([])
|
expect(offenders).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("game imports nothing from app", () => {
|
test("game imports nothing from app", () => {
|
||||||
const gameDir = new URL("../game", import.meta.url).pathname
|
const gameDir = path.join(repositoryRoot(), "game")
|
||||||
const offenders = tsFiles(gameDir).filter((f) => /from\s+["'][^"']*\/app\//.test(readFileSync(f, "utf8")))
|
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([])
|
expect(offenders).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
|
||||||
72
tests/level.test.ts
Normal file
72
tests/level.test.ts
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
38
tests/render-protocol.test.ts
Normal file
38
tests/render-protocol.test.ts
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue