sindicate 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -2
- package/docs/api/README.md +28 -0
- package/docs/api/anim.md +125 -0
- package/docs/api/assets.md +208 -0
- package/docs/api/collision.md +101 -0
- package/docs/api/fbxloader.md +50 -0
- package/docs/api/grass.md +103 -0
- package/docs/api/input.md +164 -0
- package/docs/api/quality.md +159 -0
- package/docs/api/renderer.md +115 -0
- package/docs/api/retarget.md +149 -0
- package/docs/api/shatter.md +121 -0
- package/docs/api/utils-decals.md +188 -0
- package/docs/api/water.md +143 -0
- package/docs/api/wind-weather-uniforms.md +105 -0
- package/docs/contracts/render.md +32 -0
- package/docs/contracts/time-feel.md +52 -0
- package/package.json +1 -1
- package/src/core/anim.js +184 -0
- package/src/core/assets.js +596 -0
- package/src/core/engine.js +284 -0
- package/src/core/input/bindings.js +30 -0
- package/src/core/input/gamepad.js +92 -0
- package/src/core/input/index.js +62 -0
- package/src/core/input/keyboard.js +16 -0
- package/src/core/input/mouse.js +48 -0
- package/src/core/quality.js +5 -1
- package/src/core/renderer.js +104 -0
- package/src/core/retarget.js +282 -0
- package/src/index.js +19 -2
- package/src/systems/aim.js +114 -0
- package/src/systems/campfire.js +150 -0
- package/src/systems/climbing.js +136 -0
- package/src/systems/deadeye.js +332 -0
- package/src/systems/encounters.js +123 -0
- package/src/systems/entity.js +524 -0
- package/src/systems/fistCurl.js +38 -0
- package/src/systems/footsteps.js +161 -0
- package/src/systems/glass.js +67 -0
- package/src/systems/herd.js +277 -0
- package/src/systems/horse.js +361 -0
- package/src/systems/mountedTraveller.js +518 -0
- package/src/systems/nav.js +343 -0
- package/src/systems/npc.js +880 -0
- package/src/systems/pathFollow.js +107 -0
- package/src/systems/platform.js +484 -0
- package/src/systems/riding.js +580 -0
- package/src/systems/seatedRider.js +396 -0
- package/src/systems/skybirds.js +129 -0
- package/src/systems/trainCamera.js +414 -0
- package/src/systems/traveller.js +254 -0
- package/src/systems/tumbleweeds.js +92 -0
- package/src/systems/vat.js +327 -0
- package/src/systems/vfx.js +472 -0
- package/src/world/blobShadows.js +109 -0
- package/src/world/clouds.js +61 -0
- package/src/world/flora.js +87 -0
- package/src/world/footprints.js +117 -0
- package/src/world/lampField.js +58 -0
- package/src/world/lampGlow.js +92 -0
- package/src/world/scatter.js +1077 -0
- package/src/world/sky.js +363 -0
- package/src/world/tileManager.js +197 -0
- package/src/world/walkHumps.js +74 -0
- package/src/world/weather.js +312 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# retarget — bind-pose-delta animation retargeting
|
|
2
|
+
|
|
3
|
+
`src/core/retarget.js`
|
|
4
|
+
|
|
5
|
+
Plays animation clips authored on one skeleton on characters rigged with a *different* skeleton. The canonical case: clips from the Synty ANIMATION pack rig (`Root/Hips/Spine_01/Shoulder_L…`) driving Synty POLYGON character rigs (`Pelvis/spine_01/UpperArm_L…`). The rigs share structure and proportions, but bone names differ and the bone-local frames differ (Unreal-style axis convention on the POLYGON side).
|
|
6
|
+
|
|
7
|
+
The method is **bind-pose deltas**:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Δb = Wb_animBind⁻¹ · Wb_targetBind (world-rotation delta per bone)
|
|
11
|
+
q'b(t) = Δparent(b)⁻¹ · qb(t) · Δb (applied to every quaternion key)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
This makes the target's world deformation match the source clip exactly, regardless of local frame conventions.
|
|
15
|
+
|
|
16
|
+
The module is consumed automatically by `Animator` (`src/core/anim.js`), which builds one retarget context per source rig per character and runs every registered clip through `retargetClip`. You only touch this module directly at boot (registering source rigs) or when adding a foreign animation pack.
|
|
17
|
+
|
|
18
|
+
## Why "source rigs" exist at all
|
|
19
|
+
|
|
20
|
+
Clips are authored against a specific skeleton's **bind pose**, and a clip only plays correctly on a rig with that same bind. Unity hides this — its Humanoid/Mecanim avatar retargets through muscle-space, so any humanoid clip plays on any humanoid rig. three.js has no such layer: it applies raw bone quaternions. So a pack whose bind differs — the Wild West gunslinger pack's PreviewRig is ~90–107° off on Hips/Spine vs the Synty anim rig, despite **identical bone names** — must declare its own source bind, or it plays as a folded-in-half mess.
|
|
21
|
+
|
|
22
|
+
One global source bind was the original bug: every clip was assumed to be authored on the Synty rig. The fix is the named-rig registry (`addSourceRig`) plus per-clip rig keys (see `Animator`'s `clipRigs` / `setClipDefaults({ rigs })`).
|
|
23
|
+
|
|
24
|
+
**When a game needs a custom source rig:** any time it imports clips from a pack whose skeleton bind pose differs from an already-registered rig — even if the bone names match. Register the pack's own preview/reference model as a rig under a new key, and map its clips to that key.
|
|
25
|
+
|
|
26
|
+
## Exports
|
|
27
|
+
|
|
28
|
+
### `addSourceRig(key, root, map = null)`
|
|
29
|
+
|
|
30
|
+
Register a source rig by key. `root` is the loaded model whose **current loaded pose is taken as its bind pose** (captured from skinned-mesh inverse-bind matrices, so a posed model is still safe — see Gotchas). `map` is an optional bone-name table translating this rig's bone names onto ANIM-rig names (e.g. `MALBERS_TO_ANIM`); pass `null` when the rig already uses ANIM-rig names.
|
|
31
|
+
|
|
32
|
+
The **first** rig registered becomes the default for clips that don't name a rig.
|
|
33
|
+
|
|
34
|
+
### `setSourceRig(root, map = null)`
|
|
35
|
+
|
|
36
|
+
Back-compat single-source API. Equivalent to `addSourceRig('anim', root, map)` and forces `'anim'` to be the default rig key. Use this to register the Synty animation rig.
|
|
37
|
+
|
|
38
|
+
### `detectRig(root)` → `'anim' | 'polygon'`
|
|
39
|
+
|
|
40
|
+
Classifies a target skeleton: returns `'polygon'` if any bone is named `Pelvis`, else `'anim'`. Used internally by `buildRetargetContext` to decide whether the `ANIM_TO_POLYGON` renaming step applies.
|
|
41
|
+
|
|
42
|
+
### `buildRetargetContext(targetRoot, rigKey = null)` → context
|
|
43
|
+
|
|
44
|
+
Builds a per-character retarget context against the source rig named by `rigKey` (default: the default rig). **Throws** if that rig was never registered:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
source rig '<key>' not registered — call setSourceRig()/addSourceRig() first
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Returned object:
|
|
51
|
+
|
|
52
|
+
| Field | Meaning |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `rig` | `'anim'` or `'polygon'` (from `detectRig`) |
|
|
55
|
+
| `deltas` | `Map` of source bone name → `{ delta, targetName, parentFix }` |
|
|
56
|
+
| `posScale` | scale factor for root translation keys (source→target hip-height ratio) |
|
|
57
|
+
| `bindMatches` | `true` if every bone's bind-delta is ~identity (clip can play as-is) |
|
|
58
|
+
| `needsRootFix` | `true` if any bone needed the target-parent root fix (see Gotchas) |
|
|
59
|
+
| `posQ` | quaternion converting source-container-frame position vectors into the target's frame |
|
|
60
|
+
| `sourceBind` | the source rig's captured bind map (used by `retargetClip` for parent lookup) |
|
|
61
|
+
| `rigKey` | the resolved source rig key |
|
|
62
|
+
| `key` | cache-key string: `${rigKey}:${rig}:${posScale}:${boneCount}:${bindMatches}:${bindHash}` |
|
|
63
|
+
|
|
64
|
+
Contexts are cheap; characters sharing a rig signature *could* share one, but the engine just builds one per loaded character master (per source rig).
|
|
65
|
+
|
|
66
|
+
### `retargetClip(clip, ctx)` → `THREE.AnimationClip`
|
|
67
|
+
|
|
68
|
+
Retargets a clip using a context from `buildRetargetContext`. Behavior:
|
|
69
|
+
|
|
70
|
+
- **Short-circuit:** if `ctx.rig === 'anim'` and `ctx.bindMatches` and `!ctx.needsRootFix` and `posScale ≈ 1`, the original clip is returned untouched — identity rig, full bone coverage, matching units.
|
|
71
|
+
- **Cache:** results are cached by `${clip.uuid}:${ctx.key}` in a module-level map; repeated calls are free.
|
|
72
|
+
- **Quaternion tracks:** each key is transformed `Δparent⁻¹ · q(t) · Δb`, and the track is renamed to the target bone.
|
|
73
|
+
- **Position tracks:** only the root (`Hips`) translation is kept — it drives bob/crouch — rotated by `ctx.posQ` into the target's frame, then rescaled by `posScale` into the target rig's units. All other position tracks are dropped.
|
|
74
|
+
- **Scale tracks:** dropped.
|
|
75
|
+
- **Tracks for bones not in `ctx.deltas`** (e.g. bow-prop joints riding along in a clip) are dropped.
|
|
76
|
+
|
|
77
|
+
### `MALBERS_TO_ANIM` (const)
|
|
78
|
+
|
|
79
|
+
Bone-map table: Malbers "Human" rig → ANIM rig (`R_Pelvis → Hips`, `R_L_UpperArm → Shoulder_L`, …). Used for the climb/ladder clips, which are authored on the Malbers skeleton. It maps onto ANIM-rig names deliberately: the internal `ANIM_TO_POLYGON` step then carries the result onto polygon-rigged characters automatically, so **one map serves both anim- and polygon-rigged targets**. Pass it as the `map` argument when registering the Malbers rig:
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
addSourceRig('malbers', malbersModel, MALBERS_TO_ANIM);
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Notable entries:
|
|
86
|
+
|
|
87
|
+
- `R_CG: 'Root'` — the Malbers root above the pelvis. Without it the pelvis loses parent compensation and the body pitches 90°.
|
|
88
|
+
- Bone names use underscores (`R_L_Clavicle`, not `"R_L Clavicle"`) because FBXLoader sanitizes spaces to underscores on load.
|
|
89
|
+
|
|
90
|
+
### Not exported (but you should know they exist)
|
|
91
|
+
|
|
92
|
+
- **`ANIM_TO_POLYGON`** — module-private table mapping ANIM rig bone names → POLYGON rig names (`Hips → Pelvis`, `Shoulder_L → UpperArm_L`, finger chains, etc.). Identity entries are implied for rigs (e.g. the Knights pack) that reuse the source names. Applied automatically inside `buildRetargetContext` when the target is detected as `'polygon'`.
|
|
93
|
+
- **`captureBind(root)`** — module-private. Captures a rig's bind pose as a map of bone name → `{ worldQ, localP, parentName }` (plus a `chainQ` container rotation on the map itself). The world rotations come from the SkinnedMesh **inverse-bind matrices** (`boneInverses[i]⁻¹` *is* the bone's world matrix at bind), which are pose-independent ground truth, then get folded into the model-root frame via the skeleton container's rotation. Bones with no skin weight fall back to their live world quaternion. The details here are the source of most of the Gotchas below.
|
|
94
|
+
|
|
95
|
+
## Usage
|
|
96
|
+
|
|
97
|
+
Boot-time setup and manual retargeting (the `Animator` class does the last two steps for you):
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
import {
|
|
101
|
+
setSourceRig, addSourceRig, MALBERS_TO_ANIM,
|
|
102
|
+
buildRetargetContext, retargetClip,
|
|
103
|
+
} from 'sindicate';
|
|
104
|
+
|
|
105
|
+
// 1. Register source rigs once, at boot, after loading the reference models.
|
|
106
|
+
// First/explicit 'anim' registration becomes the default.
|
|
107
|
+
setSourceRig(syntyAnimRigModel); // Synty ANIMATION pack rig
|
|
108
|
+
addSourceRig('malbers', malbersHuman, MALBERS_TO_ANIM); // climb/ladder clips
|
|
109
|
+
addSourceRig('western', gunslingerPreviewRig); // same names, different bind!
|
|
110
|
+
|
|
111
|
+
// 2. Per character, build a context against the rig the clip was authored on.
|
|
112
|
+
const ctx = buildRetargetContext(characterModel); // default ('anim')
|
|
113
|
+
const climbCtx = buildRetargetContext(characterModel, 'malbers');
|
|
114
|
+
|
|
115
|
+
// 3. Retarget clips (cached — cheap to call repeatedly).
|
|
116
|
+
const mixer = new THREE.AnimationMixer(characterModel);
|
|
117
|
+
mixer.clipAction(retargetClip(runClip, ctx)).play();
|
|
118
|
+
mixer.clipAction(retargetClip(climbClip, climbCtx));
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
In the engine proper you normally do step 1 only, then let `Animator` handle 2–3, telling it which clips come from which rig via `setClipDefaults({ rigs: { climb: 'malbers', quickdraw: 'western' } })`.
|
|
122
|
+
|
|
123
|
+
## Gotchas
|
|
124
|
+
|
|
125
|
+
Hard-won lessons preserved from the source. Most of these are bug histories — do not "simplify" them away.
|
|
126
|
+
|
|
127
|
+
- **Bind pose must come from inverse-bind matrices, not the live pose.** Reading `getWorldQuaternion()` off live bones silently poisons every delta if the model is captured mid-animation — e.g. a shared master that got posed before this clone. That is exactly why the `SK_Chr_Soldier_*` guards held their arms out: their arm deltas were ~30° off true bind. `boneInverses[i]⁻¹` is pose-independent ground truth.
|
|
128
|
+
|
|
129
|
+
- **`boneInverses` are in the skeleton *container's* space, not world.** FBXLoader parks the Z-up→Y-up conversion on container `Object3D`s for some packs (Wild West gunslinger: root Group −90° X, PreviewRig Group −180° Z) and on a Root *bone* for others (Synty: containers identity). Comparing a bind captured in one frame against a bind captured in the other tilts every delta by that rotation — the character animates lying on his back. `captureBind` folds the container's world rotation in so both rigs are captured in the model-root frame. This is a static transform, so it stays safe for posed shared masters.
|
|
130
|
+
|
|
131
|
+
- **…and the container rotation is measured relative to `root`, not the world.** `getWorldQuaternion()` also picks up wherever the character happens to be *standing* — and a Character's model hangs under a root Group rotated to its **heading**. The bind captured for a townsman facing south had his heading folded into it, and every delta was off by exactly that angle: measured across the town, the chain rotation came out at precisely |heading| for every NPC (Silas at heading π captured a 180° "bind" and stood with his arms straight up; the player and the barkeep face heading 0, which is the only reason they ever looked right).
|
|
132
|
+
|
|
133
|
+
- **Root fix: identity is the wrong fallback for source bones with no parent.** Packs whose root bone is Hips (no Root — e.g. the gunslinger rig) have no parent delta for Hips. But the *target's* Hips hangs under a Root bone carrying its own bind rotation, which then never cancels. The correct fallback is the **target parent's inverse bind world rotation** (`parentFix`), derived from `q_local = W_parent⁻¹ · q_src(t) · Δb`. With identity, the whole character renders upside down / rolled by the root's bind.
|
|
134
|
+
|
|
135
|
+
- **Root translation is unit-sensitive.** Position tracks are authored in the source rig's units (cm). A metre-scale pack (e.g. the Modular Fantasy Hero) needs them rescaled or the pelvis flies 87 m up. `posScale` is the target/source hip-height ratio — and note this is why the identity short-circuit in `retargetClip` also checks `posScale ≈ 1`: a metre-scale rig with matching bone names still needs the rescale pass.
|
|
136
|
+
|
|
137
|
+
- **`posScale` of zero pinned characters to the floor.** A target whose Hips sits at its parent's origin (`localP.y === 0` — true of the western Synty rigs, where Root and Hips are coincident) used to yield `posScale = 0`, multiplying every `Hips.position` key by zero: the pelvis got pinned to the root and the character sank into the ground. Rescale only happens when *both* hip heights are real and the ratio is sane (`0.01 < posScale < 100`); otherwise 1:1.
|
|
138
|
+
|
|
139
|
+
- **Hips translation is a vector in the source container's frame.** Rotating only the quaternion tracks left it in the pack's Z-up space: the pelvis got driven to the floor (hipsY 0) while the body above it posed correctly. `retargetClip` rotates the position keys by `ctx.posQ` *then* rescales.
|
|
140
|
+
|
|
141
|
+
- **Same bone names ≠ same bind pose — twice over.** (1) The gunslinger pack: identical names, ~90–107° different bind, hence named source rigs. (2) The Fantasy Rivals bosses each carry a custom rest stance (bent legs, etc.) on anim-rig bone names — the clip is only usable as-is when the bind *also* matches (`bindMatches` = every delta ~identity, `|w| ≈ 1`).
|
|
142
|
+
|
|
143
|
+
- **The cache key folds in a hash of the bind-deltas.** Every custom-posed boss otherwise produces the *same* key (same rig, same bone count), so they'd share one retargeted clip — the first one computed — and wear each other's skeleton. The hash makes each distinct bind pose unique while still letting identical rigs (e.g. many goblins) share the cache.
|
|
144
|
+
|
|
145
|
+
- **Registration order matters.** The first `addSourceRig` call sets the default rig key; `setSourceRig` forces the default to `'anim'`. Register the Synty rig before anything constructs an `Animator`, or `buildRetargetContext` throws.
|
|
146
|
+
|
|
147
|
+
- **Dropped tracks are intentional.** Non-Hips position tracks, all scale tracks, and tracks for bones with no delta entry (prop joints riding in a clip) are discarded, not errors.
|
|
148
|
+
|
|
149
|
+
- **FBXLoader sanitizes bone names.** Spaces become underscores (`"R_L Clavicle"` loads as `R_L_Clavicle`). Bone-map tables must use the *loaded* names.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Shatter
|
|
2
|
+
|
|
3
|
+
`src/systems/shatter.js` — physics-y debris for smashed props (pots, urns, pre-fractured smashables). When something is smashed, `Shatter` clones the pack's real shard meshes (e.g. `SM_Prop_Vase_Shard_01..04`), flings them out with velocity and spin, drops them under gravity, bounces and settles them on the floor, then fades them out and removes them. It is pure CPU integration over a tiny transient list — no physics engine, no per-shard lights, nothing resident: shard meshes are added to the scene on smash and removed once faded.
|
|
4
|
+
|
|
5
|
+
Exported from the package barrel (`export * from './systems/shatter.js'` in `src/index.js`).
|
|
6
|
+
|
|
7
|
+
## Why it's built this way (PERF LAW)
|
|
8
|
+
|
|
9
|
+
The engine's WebGPU doctrine (see `ENGINE_DESIGN.md`, P2) is: everything resident at boot, never add/remove skinned meshes mid-play, fixed light counts, no pipeline compiles after boot. Shatter is the deliberate, minimal way to have mid-play debris inside that doctrine:
|
|
10
|
+
|
|
11
|
+
- Shards are plain static mesh clones — never skinned, never lit individually.
|
|
12
|
+
- The "pool" (`this.pieces`) is a tiny, short-lived array; each burst adds a handful of pieces and every piece removes itself after settle + fade. Nothing accumulates.
|
|
13
|
+
- All motion is closed-form CPU integration in `update(dt)` — no physics engine dependency.
|
|
14
|
+
- Cloned shard materials are of types the scene already renders, so bursts reuse existing pipelines. The design doc plans to make this a hard guarantee (shatter materials warm-registered at boot so the first mid-fight burst can never compile a pipeline); today it is discipline by convention — keep shard templates using materials that have already been rendered.
|
|
15
|
+
|
|
16
|
+
Note the word "pool" here means *bounded transient set*, not object reuse: clones are created per burst and fully disposed after fading.
|
|
17
|
+
|
|
18
|
+
## Injection
|
|
19
|
+
|
|
20
|
+
The scene is constructor-injected — the class holds no globals and touches nothing but the `THREE.Scene` you hand it. This file is part of the "verified-clean" migration tier: moved verbatim from the western game because it already imported clean, with the scene as its only external dependency.
|
|
21
|
+
|
|
22
|
+
## Exports
|
|
23
|
+
|
|
24
|
+
### `class Shatter`
|
|
25
|
+
|
|
26
|
+
#### `constructor(scene)`
|
|
27
|
+
|
|
28
|
+
- `scene` — the `THREE.Scene` shard meshes are added to and removed from.
|
|
29
|
+
|
|
30
|
+
Creates an empty pool (`this.pieces = []`).
|
|
31
|
+
|
|
32
|
+
#### `burst(pos, frags, opts = {})`
|
|
33
|
+
|
|
34
|
+
Spawn one burst of shards. No-op if `frags` is missing or empty.
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
burst(pos, frags, { count = 8, floorY = pos.y, spread = 2.4, up = 3.2, scale = 1, dir = null, ry = 0 } = {})
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- `pos` — world `THREE.Vector3`, base of the smashed prop.
|
|
41
|
+
- `frags` — array of template `Object3D`s to clone (the shard meshes). Cycled with `i % frags.length` when `count` exceeds the template count.
|
|
42
|
+
- `count` — how many shards to throw (default 8).
|
|
43
|
+
- `floorY` — Y the shards rest on (default `pos.y`). Each piece actually settles at `floorY + 0.04..0.08` (random) so shards stack slightly instead of z-fighting on one plane.
|
|
44
|
+
- `spread` — horizontal fling speed scale (default 2.4).
|
|
45
|
+
- `up` — upward fling speed scale (default 3.2).
|
|
46
|
+
- `scale` — uniform scale multiplier applied to each clone (default 1).
|
|
47
|
+
- `dir` — optional unit `THREE.Vector3`; the blow "carries through": `spread * (0.5..0.9)` of extra velocity along `dir` is added to every shard.
|
|
48
|
+
- `ry` — the prop's yaw in radians. Only meaningful for pivot templates (below).
|
|
49
|
+
|
|
50
|
+
Two spawn modes, chosen per template:
|
|
51
|
+
|
|
52
|
+
1. **Pre-fractured smashables** — templates carrying `userData.pivot` (a local-space offset) spawn at their *true position inside the prop* (pivot rotated by `ry`, scaled, offset from `pos`), start in the prop's rest orientation (`rotation.set(0, ry, 0)`), and fly *outward from the prop's centre*, so the prop visibly breaks apart along its fracture planes instead of debris materialising at a point. These chunky shards tumble slower (`spinScale = 0.55`).
|
|
53
|
+
2. **Generic shards** (no pivot) — spawn in a small random cloud around `pos` with fully random orientation and random outward velocity.
|
|
54
|
+
|
|
55
|
+
Every clone gets **independent material instances** (`material.clone()`, arrays handled) so each shard can fade on its own without affecting the templates or its siblings.
|
|
56
|
+
|
|
57
|
+
#### `update(dt)`
|
|
58
|
+
|
|
59
|
+
Advance every piece one frame. Call this once per frame from the game loop. Early-returns when the pool is empty. `dt` is clamped to `0.04` s internally so a hitched frame can't tunnel shards through the floor.
|
|
60
|
+
|
|
61
|
+
Per-piece lifecycle:
|
|
62
|
+
|
|
63
|
+
1. **Flying** — gravity (`G = 17`, tuned snappy for low-poly debris), position integration, Euler spin.
|
|
64
|
+
2. **Bounce** — on crossing `floorY`: Y velocity reflected with restitution `REST = 0.34`, horizontal velocity kept at `FRICTION = 0.62` per bounce, spin damped to 45 %.
|
|
65
|
+
3. **Rest** — when a bounce leaves `|vel.y| < 0.7` and horizontal speed² `< 0.4`, the piece rests (spin zeroed).
|
|
66
|
+
4. **Settle hold** — a rested shard lingers `SETTLE_HOLD = 1.4` s.
|
|
67
|
+
5. **Fade** — opacity ramps to 0 over `FADE = 1.1` s (`transparent = true`, `depthWrite = false` forced on its materials).
|
|
68
|
+
6. **Removal** — at opacity 0 the mesh is removed from the scene, its cloned materials disposed, and the piece spliced out (the loop iterates backwards so splicing is safe).
|
|
69
|
+
|
|
70
|
+
#### `clear()`
|
|
71
|
+
|
|
72
|
+
Immediately remove every live shard from the scene, dispose their cloned materials, and empty the pool. Call on scene teardown / level change — pieces mid-flight will otherwise stay in the scene forever since only `update` retires them.
|
|
73
|
+
|
|
74
|
+
## Usage
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
import * as THREE from 'three/webgpu';
|
|
78
|
+
import { Shatter } from 'sindicate';
|
|
79
|
+
|
|
80
|
+
const shatter = new Shatter(scene);
|
|
81
|
+
|
|
82
|
+
// Loaded once at boot: the pack's real shard meshes.
|
|
83
|
+
const vaseShards = [shard01, shard02, shard03, shard04];
|
|
84
|
+
|
|
85
|
+
// On smash — e.g. a melee hit lands on a pot:
|
|
86
|
+
function smashPot(pot, hitDir) {
|
|
87
|
+
shatter.burst(pot.position.clone(), vaseShards, {
|
|
88
|
+
count: 10,
|
|
89
|
+
floorY: pot.position.y, // pot sits on the floor
|
|
90
|
+
dir: hitDir, // unit vector — the blow carries through
|
|
91
|
+
scale: 0.9,
|
|
92
|
+
});
|
|
93
|
+
scene.remove(pot);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Pre-fractured prop: templates carry userData.pivot, so pass the prop's yaw
|
|
97
|
+
// and the shards fly apart along the fracture planes.
|
|
98
|
+
shatter.burst(crate.position.clone(), crate.userData.fragments, {
|
|
99
|
+
count: crate.userData.fragments.length,
|
|
100
|
+
ry: crate.rotation.y,
|
|
101
|
+
dir: hitDir,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Frame loop:
|
|
105
|
+
shatter.update(dt);
|
|
106
|
+
|
|
107
|
+
// Level teardown:
|
|
108
|
+
shatter.clear();
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Gotchas
|
|
112
|
+
|
|
113
|
+
- **Geometry is shared with the templates and must never be disposed.** Only the *materials* are cloned per shard (so each fades independently); the internal `disposeMats` helper frees exactly those clones on removal and deliberately leaves geometry alone. If you ever touch removal code, keep that asymmetry — disposing geometry here would destroy the templates for every future burst.
|
|
114
|
+
- **You must drive `update(dt)` every frame and call `clear()` on teardown.** There is no timer or scene hook; a Shatter that stops being updated leaves its shards frozen in the scene.
|
|
115
|
+
- **`dt` is clamped to 0.04 s** inside `update` — a long hitch slows the debris rather than letting shards tunnel through the floor or fly across the map.
|
|
116
|
+
- **Templates cycle.** `count` larger than `frags.length` reuses templates round-robin (`i % frags.length`); asking for 12 shards from 4 templates is fine and normal.
|
|
117
|
+
- **`userData.pivot` changes everything about a template**: spawn position (true position inside the prop, rotated by `ry`), initial orientation (prop's rest orientation, not random), outward-from-centre velocity, and slower tumble (`spinScale = 0.55`). Generic point-burst behaviour only applies to templates without it.
|
|
118
|
+
- **Fading mutates the cloned materials** (`transparent = true`, `depthWrite = false`). This is safe precisely because they are per-shard clones — another reason not to "optimise" the clone away.
|
|
119
|
+
- **Mid-play adds are the point, but keep them cheap.** The whole design exists to add/remove *only* tiny unskinned meshes mid-play, with materials whose pipelines already exist. Don't extend it to spawn lights, skinned meshes, or novel material types per burst — that breaks the PERF LAW this module was shaped by. (Future engine work warm-registers shatter materials at boot to enforce this; see `ENGINE_DESIGN.md`.)
|
|
120
|
+
- **Settle heights are jittered** (`floorY + 0.04..0.08`) so stacked shards don't z-fight on a single plane — if debris looks like it floats slightly, that offset is intentional.
|
|
121
|
+
- The tuning constants (`G = 17`, `REST = 0.34`, `FRICTION = 0.62`, `SETTLE_HOLD = 1.4`, `FADE = 1.1`) are module-private, not exported — they are tuned as a set for snappy low-poly debris, not meant as per-game knobs.
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Core Utils & DecalField
|
|
2
|
+
|
|
3
|
+
Two small core modules:
|
|
4
|
+
|
|
5
|
+
- `src/core/utils.js` — shared math/noise helpers and scratch objects used across the engine.
|
|
6
|
+
- `src/core/decalField.js` — `DecalField`, a portable ground/surface decal system (blood splats, scorch marks, sigils, footprints) with a ring-buffer budget.
|
|
7
|
+
|
|
8
|
+
Both are re-exported from `src/index.js` (e.g. `export { DecalField } from './core/decalField.js'`).
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## utils.js
|
|
13
|
+
|
|
14
|
+
Small shared helpers. Imports `three/webgpu` and `ImprovedNoise` from three's addons.
|
|
15
|
+
|
|
16
|
+
### Exports
|
|
17
|
+
|
|
18
|
+
#### `noise: ImprovedNoise`
|
|
19
|
+
|
|
20
|
+
A single shared `ImprovedNoise` instance. `fbm` samples it; you can sample it directly with `noise.noise(x, y, z)`.
|
|
21
|
+
|
|
22
|
+
#### `mulberry32(seed) → () => number`
|
|
23
|
+
|
|
24
|
+
Deterministic pseudo-random generator for reproducible world generation. Returns a function that yields floats in `[0, 1)`. The seed is coerced with `seed >>> 0`, so any number is accepted; the same seed always produces the same sequence. Use this — not `Math.random()` — anywhere world-gen output must be reproducible across runs.
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
const rng = mulberry32(1234);
|
|
28
|
+
rng(); // same value every run for seed 1234
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
#### `fbm(x, z, octaves = 4, lacunarity = 2, gain = 0.5, scale = 0.01) → number`
|
|
32
|
+
|
|
33
|
+
Fractal Brownian motion over the shared `noise` instance, sampled in the XZ plane. Each octave samples `noise.noise(x * freq, z * freq, 7.31 + i * 13.7)` — the third coordinate is a fixed per-octave slice offset, so octaves decorrelate but the field is fully determined by `(x, z)` and the parameters. The result is normalized by the total amplitude (`sum / norm`), so output stays in roughly `[-1, 1]` regardless of octave count.
|
|
34
|
+
|
|
35
|
+
#### `clamp(v, a, b) → number`
|
|
36
|
+
|
|
37
|
+
`Math.min(b, Math.max(a, v))`.
|
|
38
|
+
|
|
39
|
+
#### `lerp(a, b, t) → number`
|
|
40
|
+
|
|
41
|
+
`a + (b - a) * t`.
|
|
42
|
+
|
|
43
|
+
#### `smoothstep(a, b, t) → number`
|
|
44
|
+
|
|
45
|
+
Classic Hermite smoothstep: clamps `(t - a) / (b - a)` to `[0, 1]`, then `x * x * (3 - 2x)`. Note the argument order: edges first, value last.
|
|
46
|
+
|
|
47
|
+
#### `dist2D(ax, az, bx, bz) → number`
|
|
48
|
+
|
|
49
|
+
Euclidean distance in the XZ plane via `Math.sqrt(dx*dx + dz*dz)`.
|
|
50
|
+
|
|
51
|
+
> **Perf note (from the source):** `Math.hypot` is 5–10x slower in V8 — this is THE hot distance helper. Do not "clean it up" to `Math.hypot`; it is called constantly in hot paths.
|
|
52
|
+
|
|
53
|
+
#### `angleLerp(a, b, t) → number`
|
|
54
|
+
|
|
55
|
+
Shortest-arc angle interpolation for smooth turning. Wraps the delta into `(-π, π]` before lerping, so turning from `0.1` to `2π - 0.1` goes the short way through 0 instead of spinning almost a full turn.
|
|
56
|
+
|
|
57
|
+
#### `tmpV1`, `tmpV2`, `tmpV3: THREE.Vector3` · `tmpQ1: THREE.Quaternion`
|
|
58
|
+
|
|
59
|
+
Shared scratch objects for allocation-free math in per-frame code.
|
|
60
|
+
|
|
61
|
+
### Usage example
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
import { mulberry32, fbm, dist2D, angleLerp, clamp, tmpV1 } from './core/utils.js';
|
|
65
|
+
|
|
66
|
+
const rng = mulberry32(worldSeed); // reproducible per seed
|
|
67
|
+
const h = fbm(wx, wz, 5, 2, 0.5, 0.008); // terrain height sample, ~[-1, 1]
|
|
68
|
+
|
|
69
|
+
// hot path: chase steering
|
|
70
|
+
const d = dist2D(enemy.x, enemy.z, player.x, player.z);
|
|
71
|
+
if (d < aggroRange) {
|
|
72
|
+
const want = Math.atan2(player.x - enemy.x, player.z - enemy.z);
|
|
73
|
+
enemy.heading = angleLerp(enemy.heading, want, clamp(turnRate * dt, 0, 1));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
tmpV1.set(enemy.x, 0, enemy.z); // scratch — do not retain
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Gotchas
|
|
80
|
+
|
|
81
|
+
- **`dist2D` over `Math.hypot`, always.** The 5–10x V8 penalty is why this helper exists; it is the engine's hot distance function.
|
|
82
|
+
- **`mulberry32` for anything that must be reproducible.** World generation is seeded through it; mixing in `Math.random()` breaks determinism.
|
|
83
|
+
- **`tmpV1/2/3` and `tmpQ1` are shared module-level scratch.** Never store them, return them, or hold them across a call that might also use them — values are clobbered by the next user. Copy out if you need to keep a result.
|
|
84
|
+
- **`fbm` samples a fixed 3D slice** (`7.31 + i * 13.7` per octave); two calls with identical args always match, and octaves don't self-correlate.
|
|
85
|
+
- **`smoothstep(a, b, t)`** takes edges first — not the `(t)`-only GLSL convenience form.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## decalField.js
|
|
90
|
+
|
|
91
|
+
`DecalField` — portable ground/surface decals for the unity-vfx-webgpu format. Lays flat textured quads on the surface they are projected onto, oriented to the surface normal, with fade-in/out over life and a ring-buffer budget. Contains no game-specific code.
|
|
92
|
+
|
|
93
|
+
**These are simple laid-flat quads, NOT true projected decals** — there is no mesh-conforming UV projection. They are cheap, WebGPU-safe, and enough for a flat-ish ground surface. On sharply curved or stepped geometry a large decal will float or clip.
|
|
94
|
+
|
|
95
|
+
### `class DecalField`
|
|
96
|
+
|
|
97
|
+
#### `constructor(scene, { loadTexture, textureBase = '', max = 128 } = {})`
|
|
98
|
+
|
|
99
|
+
Injection points — the field owns no loader or scene of its own:
|
|
100
|
+
|
|
101
|
+
- `scene` — a `THREE.Scene` (or any `Object3D` container) that decal meshes are added to / removed from.
|
|
102
|
+
- `loadTexture(nameOrUrl) → Promise<Texture>` — injected texture loader. All texture I/O goes through this.
|
|
103
|
+
- `textureBase` — when set, bare basenames in recipes resolve to `${textureBase}/${name}.png` (normal maps too). When absent, the basename is passed to `loadTexture` unchanged.
|
|
104
|
+
- `max` — ring-buffer budget. When the `(max + 1)`th decal is added, the oldest is evicted (removed from scene, geometry and material disposed).
|
|
105
|
+
|
|
106
|
+
Public state: `recipes` (name → recipe), `textures` (basename → `THREE.Texture` or `null` on load failure), `items` (active decals, oldest first — the ring buffer).
|
|
107
|
+
|
|
108
|
+
#### `async load(decalRecipes)`
|
|
109
|
+
|
|
110
|
+
Stores the recipe table and pre-resolves every texture (and normal map) referenced by any recipe, via the injected `loadTexture`. A failed load warns `[decal] missing tex <name>` and stores `null` — later `add()` calls for that recipe render an untextured tinted quad rather than throwing. Call once at init, before `add()`.
|
|
111
|
+
|
|
112
|
+
**Recipe schema** (all fields optional):
|
|
113
|
+
|
|
114
|
+
| Field | Type | Meaning |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| `tex` | string | Albedo texture basename/URL |
|
|
117
|
+
| `norm` | string | Normal-map basename (preloaded — see gotchas) |
|
|
118
|
+
| `sz` | `[min, max]` | Base size range in metres, sampled uniformly per spawn (default `[1, 1]`) |
|
|
119
|
+
| `aspect` | number | Width = `size * aspect`, height = `size` (default 1, square) |
|
|
120
|
+
| `tint` | `[r, g, b, a]` | Authored color/alpha multiplier (default `[1,1,1,1]`) |
|
|
121
|
+
| `blend` | `'a'` | Additive blending; anything else = normal blending |
|
|
122
|
+
| `rot` | `'rand'` \| degrees | Spin about the surface normal |
|
|
123
|
+
| `yOffset` | number | Lift along the normal in metres (default `0.02`) |
|
|
124
|
+
| `life` | number | Seconds until auto-removal; `0`/absent = persistent |
|
|
125
|
+
| `fade` | `[in, out]` | Fade-in / fade-out durations in seconds |
|
|
126
|
+
|
|
127
|
+
#### `add(name, { pos, normal = [0, 1, 0], scale = 1, rot, color } = {}) → { decal, remove } | null`
|
|
128
|
+
|
|
129
|
+
Lays a decal from recipe `name` at world `pos` (accepts `{x,y,z}` or `[x,y,z]`), oriented so its face points along `normal` (an `[x,y,z]` array; degenerate normals fall back to `+Y`). Returns `null` if the recipe doesn't exist.
|
|
130
|
+
|
|
131
|
+
- `scale` multiplies the authored size.
|
|
132
|
+
- `rot` overrides the recipe's rotation (`'rand'` or degrees).
|
|
133
|
+
- `color` multiplies the authored tint — accepts an `[r,g,b,(a)]` array, a `THREE.Color`, or anything `new THREE.Color(color)` accepts (only arrays can scale alpha).
|
|
134
|
+
|
|
135
|
+
Internals worth knowing:
|
|
136
|
+
|
|
137
|
+
- Geometry is a fresh `PlaneGeometry`; three's plane faces `+Z`, so orientation is `setFromUnitVectors(+Z, normal)`, then the spin quaternion is premultiplied about the normal.
|
|
138
|
+
- Material is `MeshBasicMaterial` with `transparent: true`, `depthWrite: false`, `polygonOffset` (`factor/units = -4`), `DoubleSide`, `fog: false`. polygonOffset beats z-fighting against the surface the decal lies on; depthWrite off + transparent makes overlapping splats blend instead of clipping each other.
|
|
139
|
+
- Position is lifted along the normal by `yOffset` (default 0.02 m) as a second z-fighting guard.
|
|
140
|
+
- Opacity spawns at the correct first-frame alpha — `0` when fading in — so decals never pop for one frame at full alpha.
|
|
141
|
+
- After pushing, the ring buffer evicts oldest items past `max` (scene remove + geometry/material dispose).
|
|
142
|
+
|
|
143
|
+
The returned handle's `remove()` deletes that specific decal early, regardless of its life.
|
|
144
|
+
|
|
145
|
+
#### `update(dt)`
|
|
146
|
+
|
|
147
|
+
Advance ages, apply fades, expire dead decals. Iterates backwards so expiry splices are safe. `life > 0` decals are disposed when `age >= life`; fade-in ramps alpha up over `fade[0]`, fade-out ramps down over the last `fade[1]` seconds. Persistent decals (`life` 0/absent) fade in and never fade out. **Must be called every frame** for fades and expiry to happen.
|
|
148
|
+
|
|
149
|
+
#### `clear()`
|
|
150
|
+
|
|
151
|
+
Dispose every active decal and empty the buffer (level unload / restart).
|
|
152
|
+
|
|
153
|
+
### Usage example
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
import { DecalField } from 'sindicate';
|
|
157
|
+
|
|
158
|
+
const decals = new DecalField(scene, {
|
|
159
|
+
loadTexture: (url) => textureLoader.loadAsync(url),
|
|
160
|
+
textureBase: 'assets/decals', // 'blood' -> assets/decals/blood.png
|
|
161
|
+
max: 96,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await decals.load({
|
|
165
|
+
blood: { tex: 'blood', sz: [0.6, 1.2], rot: 'rand', tint: [0.7, 0.05, 0.05, 0.9], life: 20, fade: [0.1, 4] },
|
|
166
|
+
scorch: { tex: 'scorch', sz: [1.5, 2.0], rot: 'rand', blend: 'a', life: 0, fade: [0.2, 0] }, // persistent
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// on hit:
|
|
170
|
+
const h = decals.add('blood', { pos: hit.point, normal: [hit.normal.x, hit.normal.y, hit.normal.z], scale: 1.3 });
|
|
171
|
+
// h.remove() to delete early
|
|
172
|
+
|
|
173
|
+
// per frame:
|
|
174
|
+
decals.update(dt);
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Gotchas
|
|
178
|
+
|
|
179
|
+
- **Not projected decals.** Flat quads oriented to a single normal — intentional: cheap and WebGPU-safe. Don't expect them to wrap stairs, rocks, or walls-meeting-floors.
|
|
180
|
+
- **Ring buffer is the only budget.** Nothing throttles `add()`; spamming it just evicts your oldest decals (including "persistent" ones — persistence only means no lifetime expiry, not eviction immunity). Size `max` for the worst-case burst you care about.
|
|
181
|
+
- **Call `update(dt)` every frame.** Fades and `life` expiry live there; skip it and timed decals never die.
|
|
182
|
+
- **`load()` before `add()`.** `add()` reads `this.textures` synchronously; unresolved textures render as untextured tinted quads (missing textures warn once at load, then silently degrade).
|
|
183
|
+
- **Two z-fighting defenses, keep both.** `polygonOffset(-4, -4)` plus the `yOffset` lift along the normal (default 0.02 m). Removing either brings the flicker back on coplanar ground.
|
|
184
|
+
- **`depthWrite: false` is load-bearing.** Overlapping splats must blend; with depth writes on they clip each other.
|
|
185
|
+
- **First-frame alpha is set at spawn** (`0` when `fadeIn > 0`, else the composed alpha) specifically so decals never pop at full opacity for a frame before the first `update()`.
|
|
186
|
+
- **`norm` maps are preloaded but not currently applied.** `load()` fetches them into `this.textures`, but `add()` builds a `MeshBasicMaterial` with only `map` — the recipe field is plumbed for the format, not yet wired into the material.
|
|
187
|
+
- **Per-decal geometry and material.** Each `add()` allocates a `PlaneGeometry` + `MeshBasicMaterial`; both are disposed on eviction/expiry/`clear()`. If you bypass `remove()`/`clear()` and pull meshes out yourself, you leak GPU resources.
|
|
188
|
+
- **Spawn randomness uses `Math.random()`** (size within `sz`, `'rand'` rotation) — decal placement is not part of the deterministic `mulberry32` world-gen path.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Water
|
|
2
|
+
|
|
3
|
+
`src/world/water.js`, `src/world/waterNoise.js`
|
|
4
|
+
|
|
5
|
+
Low-poly water with **see-through depth**. The core techniques are ported from Braffolk's fable5-world-demo `WaterMaterial` (same three.js/TSL stack), restyled for the faceted Synty look. The surface refracts the actual rendered scene behind it (`viewportSharedTexture`) with per-channel Beer–Lambert absorption by water-column thickness, and reflects the sky-phase colours (`uSkyCol` / `uHorizonCol` from `weatherUniforms.js`) by fresnel — so riverbeds and wading legs show through the shallows, deep water goes murky, and the water tracks dawn/dusk/night/moor-dread for free. Kept from the house style: the gentle faceted swell, per-vertex `aDepth` (foam + inside-gating), the pond's `rClip` sprawl guard, and dive-under-terrain shoreline geometry.
|
|
6
|
+
|
|
7
|
+
Both materials are `THREE.MeshBasicNodeMaterial` (TSL node materials, WebGPU renderer) with `transparent: true, depthWrite: true`.
|
|
8
|
+
|
|
9
|
+
## Exports
|
|
10
|
+
|
|
11
|
+
### `waterMaterial(options = {}) → THREE.MeshBasicNodeMaterial`
|
|
12
|
+
|
|
13
|
+
Still water: lakes, ponds, the sea.
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
waterMaterial({
|
|
17
|
+
foam = 0xeef8f6, // foam colour
|
|
18
|
+
shallow = 0x3f96a8, // baked-ramp shallow colour (only used when refract: false)
|
|
19
|
+
deep = 0x114f78, // baked-ramp deep colour (only used when refract: false)
|
|
20
|
+
maxDepth = 2.0, // aDepth at which the baked ramp reaches `deep`
|
|
21
|
+
foamDepth = 0.18, // water depth (m) under which shoreline foam appears
|
|
22
|
+
rClip = 21, // pond radius clip — a number, or 'attribute' (see below)
|
|
23
|
+
waveHeight = 0.16, // vertical swell amplitude (local Z)
|
|
24
|
+
waveLen = 9, // swell wavelength (m)
|
|
25
|
+
waveSpeed = 0.7, // swell + foam animation rate
|
|
26
|
+
refract = true, // false = cheap baked shallow→deep ramp, no viewport copy
|
|
27
|
+
})
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Geometry contract.** The mesh is authored in local space with the pond centred at the local origin: `positionLocal.xy` is the water plane (so `positionLocal.xy.length()` is distance from pond centre) and local Z is up — the swell is *added* to the baked local Z. Shore vertices are baked **below** the terrain so the sheet dives under dry ground and the waterline is the natural surface intersection. Required attributes:
|
|
31
|
+
|
|
32
|
+
| attribute | type | meaning |
|
|
33
|
+
|-----------|-------|---------|
|
|
34
|
+
| `aDepth` | float | baked water depth at the vertex; 0 on the dry dive-skirt verts (gates opacity, drives foam and the non-refracting colour ramp) |
|
|
35
|
+
| `aRClip` | float | per-vertex clip radius — **only** when `rClip: 'attribute'` |
|
|
36
|
+
|
|
37
|
+
**`rClip` and material sharing.** `rClip` may be a number (one body per material — the sea) or the string `'attribute'`: lakes bake their radius per-vertex (`aRClip`) so **every lake shares one material**. This matters for cost, not just convenience — each distinct refracting material compiles its own `viewportSharedTexture` sampling, and each ViewportTexture node pays its own `copyFramebufferToTexture` per frame. N lakes on one material is **1 framebuffer copy, not N**.
|
|
38
|
+
|
|
39
|
+
**`refract: false`** replaces the viewport refraction with a baked `shallow → deep` colour ramp keyed on `aDepth / maxDepth`. Used for the sea: too big to pay viewport copies for water you can't see into anyway.
|
|
40
|
+
|
|
41
|
+
Foam appears wherever the water *ends* — at the shallow waterline (`aDepth → 0`) **and** at the clip radius — both edges wave-jittered.
|
|
42
|
+
|
|
43
|
+
### `riverMaterial(options = {}) → THREE.MeshBasicNodeMaterial`
|
|
44
|
+
|
|
45
|
+
Flowing river water: a ribbon mesh (built in `zones.js`) at world Y, `side: THREE.DoubleSide`. Same see-through refraction + sky fresnel as the lakes; a flowmap ripple layer and advected foam make the current read.
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
riverMaterial({
|
|
49
|
+
foam = 0xeef8f6,
|
|
50
|
+
shallow = 0x4494a4, // baked-ramp colours, only used when refract: false
|
|
51
|
+
deep = 0x11557a,
|
|
52
|
+
maxDepth = 1.3,
|
|
53
|
+
foamDepth = 0.2, // NOTE: currently accepted but unused in the body
|
|
54
|
+
flowSpeed = 0.5, // NOTE: currently accepted but unused in the body
|
|
55
|
+
refract = true,
|
|
56
|
+
})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Rivers use a denser in-scatter murk than lakes (`vec3(0.22, 0.30, 0.32)` vs `vec3(0.10, 0.16, 0.17)`) — their plain beds give refraction little to show until the riverbed-texturing pass lands, so the murk gives the stream a visible body.
|
|
60
|
+
|
|
61
|
+
Required attributes:
|
|
62
|
+
|
|
63
|
+
| attribute | type | meaning |
|
|
64
|
+
|-----------|-------|---------|
|
|
65
|
+
| `aDepth` | float | baked water depth; 0 on the dry dive-skirt verts |
|
|
66
|
+
| `aFlow` | vec2 | per-vertex downstream flow velocity (world XZ) — advects ripples and foam; its length scales ripple amplitude |
|
|
67
|
+
| `aDrop` | float | baked downstream water-level drop — rapids foam keys on this, so real rapids appear exactly where the stepped monotonic river profile steps down |
|
|
68
|
+
|
|
69
|
+
### `waterNoiseTexture() → THREE.DataTexture`
|
|
70
|
+
|
|
71
|
+
`src/world/waterNoise.js`. CPU-baked tileable water noise — braffolk's NoiseBake, boot-time edition (~10 ms). A **cached singleton**: the first call bakes, every later call returns the same texture (also consumed by `grass.js` — do not dispose it per-material).
|
|
72
|
+
|
|
73
|
+
One 256² RGBA **float** texture, `RepeatWrapping`, linear min/mag filters:
|
|
74
|
+
|
|
75
|
+
| channel | contents |
|
|
76
|
+
|---------|----------|
|
|
77
|
+
| `r` | fbm (three periodic value-noise octaves, 8/16/32 lattices, weights 0.55/0.3/0.15) |
|
|
78
|
+
| `g` | decorrelated fbm (different seed + offsets) — used for foam patterns |
|
|
79
|
+
| `b`, `a` | d(fbm)/du, d(fbm)/dv — central differences, packed as `gradient * 0.02 + 0.5` |
|
|
80
|
+
|
|
81
|
+
Because the gradients are pre-derived and packed, a ripple layer costs **one texture fetch** (the shader unpacks with `.zw.sub(0.5).div(0.02 * scale)`). The lattices use a deterministic LCG, so the bake is reproducible.
|
|
82
|
+
|
|
83
|
+
## The viewport-refraction approach
|
|
84
|
+
|
|
85
|
+
The internal `refraction(n, murk)` helper (shared by both materials) is where the see-through look comes from:
|
|
86
|
+
|
|
87
|
+
1. **Sample the scene behind the surface.** `screenUV` is offset by the surface normal's XZ (`n.xz * refrK`) — the ripple/facet-driven refraction wobble. `refrK = clamp(9/dist, 0.04, 1) * 0.05` shrinks the offset with camera distance so far water doesn't shimmer wildly.
|
|
88
|
+
2. **Depth-validate the offset UV.** The refracted sample's view-Z (via `viewportDepthTexture`) is compared against the water fragment's own Z. If the sample landed on geometry **in front of** the water (a wader, a bridge post — `zR > fragZ + 0.02`), the shader falls back to the straight `screenUV`. Without this, foreground geometry smears into the water surface.
|
|
89
|
+
3. **Beer–Lambert absorption.** Water thickness along the view ray = `fragZ − zS` (metres, from the depth texture). Per-channel transmittance `T = exp(−1.3 · thick · σ)` with σ = `(0.55, 0.24, 0.17)` — UK water: red dies first, so depths go cool green-blue and murky by ~2.5 m.
|
|
90
|
+
4. **Turbidity in-scatter.** `uSkyCol · murk` fills in what absorption removes (`col = scene·T + inscat·(1−T)`), so the murk brightens by day and dims at night automatically.
|
|
91
|
+
5. **Vertical column.** `vDepth = thick · max(|viewDirY|, 0.06)` — the *vertical* water column, used for the per-pixel shoreline feather (mm-deep water fades over the bed) and shore foam.
|
|
92
|
+
|
|
93
|
+
On top of the refracted base: fresnel-weighted sky reflection along the reflected ray, reproducing the sky dome's horizon→sky ramp, darkened ×0.78.
|
|
94
|
+
|
|
95
|
+
## Flowmap ripples and foam (rivers)
|
|
96
|
+
|
|
97
|
+
Faithful port of braffolk `WaterMaterial` 132–151 (ripples) and 283–308 (foam):
|
|
98
|
+
|
|
99
|
+
- **No-sliding-texture flowmap:** two phases half a cycle apart (`ph1`, `ph2 = ph1 + 0.5`), each advecting the noise along `aFlow`, crossfaded by `w2 = |ph1 − 0.5| · 2` so each phase's snap-back is hidden. A small constant velocity `(0.045, 0.03)` is the breeze fallback where `aFlow` ≈ 0.
|
|
100
|
+
- **One fetch per ripple layer:** slope comes straight from the pre-baked gradient channels. Two scales (0.9 and 3.4 tiles) sum into each layer. Ripple amplitude `0.007 + |aFlow| · 0.028` — their tuned value; the ripple normal is added **under** the facet silhouette (`normalize(facetN + (−slope.x, 0, −slope.y))`) so ripples wobble the refraction downstream without breaking the low-poly read.
|
|
101
|
+
- **Foam:** two decorrelated advected noise scales are blended and then **multiplied** into clumpy patches — a single thresholded fbm slides as coherent stripes. The crossfade is variance-renormalised (`÷ sqrt(w2² + (1−w2)²)`) so foam coverage doesn't pulse at blend midpoints. Shore foam keys on the per-pixel water column (`R.vDepth`); rapids foam keys on the baked `aDrop`. Total foam is capped at 0.68.
|
|
102
|
+
- **Opacity body term:** `smoothstep(0.1, 0.7, aDepth) · 0.55` guarantees the channel core always reads as *water*, not damp ground, even where refraction feathers thin.
|
|
103
|
+
|
|
104
|
+
## Usage
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
import * as THREE from 'three/webgpu';
|
|
108
|
+
import { waterMaterial, riverMaterial } from './world/water.js';
|
|
109
|
+
|
|
110
|
+
// ONE material for every lake — radius baked per-vertex means one
|
|
111
|
+
// framebuffer copy per frame no matter how many lakes are visible.
|
|
112
|
+
const lakeMat = waterMaterial({ rClip: 'attribute' });
|
|
113
|
+
|
|
114
|
+
// Pond disc authored in local XY (centre at origin, local Z = up),
|
|
115
|
+
// shore ring baked BELOW the terrain so the sheet dives under dry ground.
|
|
116
|
+
geo.setAttribute('aDepth', new THREE.BufferAttribute(depths, 1)); // 0 on the dry skirt
|
|
117
|
+
geo.setAttribute('aRClip', new THREE.BufferAttribute(radii, 1));
|
|
118
|
+
const lake = new THREE.Mesh(geo, lakeMat);
|
|
119
|
+
lake.rotation.x = -Math.PI / 2; // orient local +Z to world up
|
|
120
|
+
scene.add(lake);
|
|
121
|
+
|
|
122
|
+
// The sea: baked depth ramp, no viewport copy.
|
|
123
|
+
const seaMat = waterMaterial({ refract: false, rClip: 4000 });
|
|
124
|
+
|
|
125
|
+
// A river ribbon (geometry from zones.js: aDepth + aFlow + aDrop baked in).
|
|
126
|
+
const river = new THREE.Mesh(riverGeo, riverMaterial());
|
|
127
|
+
scene.add(river);
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Gotchas
|
|
131
|
+
|
|
132
|
+
- **Fresnel uses a hard-flattened normal.** Braffolk's lesson: the reflectance weight must follow the *mean* surface — feed the tilted per-facet normal into `(1−cosθ)^5` and big facet tilts saturate it into a white ice sheet. `fresnel()` flattens X/Z ×0.25 for the mix weight only; the full normal still shapes the reflected sky colour and shimmer, so the facets still sparkle. Related: fresnel is capped ×0.5 so the refracted bed always survives underneath, and river `rippleAmp` is tuned small for the same reason — bigger amplitudes saturate fresnel into the same white sheet.
|
|
133
|
+
- **Refraction UV must stay depth-validated.** A refracted sample landing on geometry in front of the water must fall back to the straight UV, or foreground geometry (a wader, a bridge post) smears into the surface. If you touch `refraction()`, keep the `zR > fragZ + 0.02` guard.
|
|
134
|
+
- **One material = one framebuffer copy.** Every distinct refracting material compiles its own `viewportSharedTexture` sampling and pays its own `copyFramebufferToTexture` per frame. Share materials across bodies via `rClip: 'attribute'` + `aRClip`; never instantiate a refracting material per lake.
|
|
135
|
+
- **Build the expensive nodes once.** In both materials the shared nodes (`R`, `skyShared`, `fresShared`, the facet normal) are constructed *outside* `colorNode`/`opacityNode` and referenced from both — separate calls would duplicate the viewport-texture sampling in the compiled shader. Preserve this structure when editing.
|
|
136
|
+
- **`refract: false` exists for a reason.** The sea is too big to pay viewport copies for water you can't see into — it takes the cheap baked shallow→deep ramp instead.
|
|
137
|
+
- **Sky reflection is darkened ×0.78.** Real water reflects darker than the sky (micro-roughness), and a full-brightness midday sky whites out narrow streams seen at grazing angles.
|
|
138
|
+
- **The whole surface is dimmed by sky brightness** (base + foam, not just the reflection): `lit = clamp(luma(uSkyCol)·0.55, 0.22, 1)`. Without it water glows white at dusk/night; the 0.22 floor keeps it readable in darkness.
|
|
139
|
+
- **Shoreline geometry does the waterline.** Shore verts are baked below the terrain; the swell is *added* to that baked local Z, so the waterline is the natural mesh/terrain intersection — don't "fix" verts that appear underground.
|
|
140
|
+
- **River murk is deliberately denser than lake murk** — the plain riverbeds give refraction little to show until the riverbed-texturing pass lands.
|
|
141
|
+
- **`riverMaterial`'s `foamDepth` and `flowSpeed` options are currently dead** — accepted in the signature but not referenced in the body (foam thresholds and flow timing are hard-coded to braffolk's tuned constants).
|
|
142
|
+
- **Foam must multiply, not threshold.** Two decorrelated advected scales multiplied give clumpy patches; a single thresholded fbm slides past as coherent stripes. Keep the variance-renormalised crossfade or coverage pulses at blend midpoints.
|
|
143
|
+
- **`waterNoiseTexture()` is a shared singleton** (also used by `grass.js`); its gradients are packed as `g·0.02 + 0.5`, so any consumer must unpack with `.sub(0.5).div(0.02 · scale)` — the 0.02 constant appears on both sides of the bake.
|