reze-engine 0.42.3 → 0.50.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.
Files changed (140) hide show
  1. package/README.md +40 -410
  2. package/dist/camera.d.ts +3 -0
  3. package/dist/camera.d.ts.map +1 -1
  4. package/dist/camera.js +33 -8
  5. package/dist/engine.d.ts +924 -29
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +4043 -278
  8. package/dist/graph/registry.d.ts +2 -2
  9. package/dist/graph/registry.d.ts.map +1 -1
  10. package/dist/graph/registry.js +1 -1
  11. package/dist/graph/slots.d.ts +0 -1
  12. package/dist/graph/slots.d.ts.map +1 -1
  13. package/dist/graph/slots.js +37 -9
  14. package/dist/hdr.d.ts +18 -0
  15. package/dist/hdr.d.ts.map +1 -0
  16. package/dist/hdr.js +162 -0
  17. package/dist/ibl.d.ts +19 -0
  18. package/dist/ibl.d.ts.map +1 -0
  19. package/dist/ibl.js +113 -0
  20. package/dist/ik-solver.d.ts +2 -1
  21. package/dist/ik-solver.d.ts.map +1 -1
  22. package/dist/index.d.ts +5 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +10 -0
  25. package/dist/math.d.ts +20 -1
  26. package/dist/math.d.ts.map +1 -1
  27. package/dist/math.js +23 -16
  28. package/dist/midi-loader.d.ts +10 -0
  29. package/dist/midi-loader.d.ts.map +1 -0
  30. package/dist/midi-loader.js +247 -0
  31. package/dist/model.d.ts +2 -13
  32. package/dist/model.d.ts.map +1 -1
  33. package/dist/model.js +29 -0
  34. package/dist/param-track.d.ts +48 -0
  35. package/dist/param-track.d.ts.map +1 -0
  36. package/dist/param-track.js +80 -0
  37. package/dist/physics/types.d.ts.map +1 -1
  38. package/dist/physics/types.js +3 -0
  39. package/dist/reflection.d.ts +27 -0
  40. package/dist/reflection.d.ts.map +1 -0
  41. package/dist/reflection.js +93 -0
  42. package/dist/shaders/anchor-table.d.ts +56 -0
  43. package/dist/shaders/anchor-table.d.ts.map +1 -0
  44. package/dist/shaders/anchor-table.js +128 -0
  45. package/dist/shaders/audio-api.d.ts +3 -0
  46. package/dist/shaders/audio-api.d.ts.map +1 -0
  47. package/dist/shaders/audio-api.js +81 -0
  48. package/dist/shaders/cast-api.d.ts +2 -0
  49. package/dist/shaders/cast-api.d.ts.map +1 -0
  50. package/dist/shaders/cast-api.js +121 -0
  51. package/dist/shaders/cast-layout.d.ts +21 -0
  52. package/dist/shaders/cast-layout.d.ts.map +1 -0
  53. package/dist/shaders/cast-layout.js +20 -0
  54. package/dist/shaders/lights.d.ts +79 -0
  55. package/dist/shaders/lights.d.ts.map +1 -0
  56. package/dist/shaders/lights.js +269 -0
  57. package/dist/shaders/lyrics-api.d.ts +39 -0
  58. package/dist/shaders/lyrics-api.d.ts.map +1 -0
  59. package/dist/shaders/lyrics-api.js +187 -0
  60. package/dist/shaders/materials/common.d.ts +2 -4
  61. package/dist/shaders/materials/common.d.ts.map +1 -1
  62. package/dist/shaders/materials/common.js +87 -35
  63. package/dist/shaders/midi-api.d.ts +10 -0
  64. package/dist/shaders/midi-api.d.ts.map +1 -0
  65. package/dist/shaders/midi-api.js +114 -0
  66. package/dist/shaders/passes/composite.d.ts +31 -15
  67. package/dist/shaders/passes/composite.d.ts.map +1 -1
  68. package/dist/shaders/passes/composite.js +225 -135
  69. package/dist/shaders/passes/cull.d.ts +2 -0
  70. package/dist/shaders/passes/cull.d.ts.map +1 -0
  71. package/dist/shaders/passes/cull.js +138 -0
  72. package/dist/shaders/passes/field-blit.d.ts +26 -0
  73. package/dist/shaders/passes/field-blit.d.ts.map +1 -0
  74. package/dist/shaders/passes/field-blit.js +65 -0
  75. package/dist/shaders/passes/grid.d.ts +31 -0
  76. package/dist/shaders/passes/grid.d.ts.map +1 -0
  77. package/dist/shaders/passes/grid.js +169 -0
  78. package/dist/shaders/passes/ground.d.ts +13 -1
  79. package/dist/shaders/passes/ground.d.ts.map +1 -1
  80. package/dist/shaders/passes/ground.js +170 -25
  81. package/dist/shaders/passes/hosted-api.d.ts +57 -0
  82. package/dist/shaders/passes/hosted-api.d.ts.map +1 -0
  83. package/dist/shaders/passes/hosted-api.js +166 -0
  84. package/dist/shaders/passes/id-debug.d.ts +28 -0
  85. package/dist/shaders/passes/id-debug.d.ts.map +1 -0
  86. package/dist/shaders/passes/id-debug.js +74 -0
  87. package/dist/shaders/passes/particles.d.ts +66 -0
  88. package/dist/shaders/passes/particles.d.ts.map +1 -0
  89. package/dist/shaders/passes/particles.js +279 -0
  90. package/dist/shaders/passes/scene-contract.d.ts +128 -0
  91. package/dist/shaders/passes/scene-contract.d.ts.map +1 -0
  92. package/dist/shaders/passes/scene-contract.js +207 -0
  93. package/dist/shaders/passes/sim.d.ts +34 -0
  94. package/dist/shaders/passes/sim.d.ts.map +1 -0
  95. package/dist/shaders/passes/sim.js +169 -0
  96. package/dist/shaders/passes/trails.d.ts +59 -0
  97. package/dist/shaders/passes/trails.d.ts.map +1 -0
  98. package/dist/shaders/passes/trails.js +340 -0
  99. package/dist/shaders/score-api.d.ts +10 -0
  100. package/dist/shaders/score-api.d.ts.map +1 -0
  101. package/dist/shaders/score-api.js +114 -0
  102. package/dist/shadow-cascades.d.ts +45 -0
  103. package/dist/shadow-cascades.d.ts.map +1 -0
  104. package/dist/shadow-cascades.js +70 -0
  105. package/dist/vmd-loader.d.ts +3 -2
  106. package/dist/vmd-loader.d.ts.map +1 -1
  107. package/package.json +1 -1
  108. package/src/camera.ts +31 -8
  109. package/src/engine.ts +4535 -296
  110. package/src/graph/registry.ts +2 -2
  111. package/src/graph/slots.ts +37 -9
  112. package/src/hdr.ts +156 -0
  113. package/src/ibl.ts +115 -0
  114. package/src/ik-solver.ts +1 -1
  115. package/src/index.ts +12 -0
  116. package/src/math.ts +23 -17
  117. package/src/midi-loader.ts +246 -0
  118. package/src/model.ts +31 -3
  119. package/src/param-track.ts +83 -0
  120. package/src/physics/types.ts +4 -1
  121. package/src/reflection.ts +94 -0
  122. package/src/shaders/anchor-table.ts +147 -0
  123. package/src/shaders/audio-api.ts +82 -0
  124. package/src/shaders/cast-api.ts +123 -0
  125. package/src/shaders/cast-layout.ts +20 -0
  126. package/src/shaders/lights.ts +280 -0
  127. package/src/shaders/lyrics-api.ts +202 -0
  128. package/src/shaders/materials/common.ts +89 -35
  129. package/src/shaders/midi-api.ts +116 -0
  130. package/src/shaders/passes/composite.ts +244 -136
  131. package/src/shaders/passes/cull.ts +139 -0
  132. package/src/shaders/passes/grid.ts +178 -0
  133. package/src/shaders/passes/ground.ts +172 -25
  134. package/src/shaders/passes/hosted-api.ts +171 -0
  135. package/src/shaders/passes/id-debug.ts +75 -0
  136. package/src/shaders/passes/particles.ts +340 -0
  137. package/src/shaders/passes/scene-contract.ts +266 -0
  138. package/src/shaders/passes/trails.ts +390 -0
  139. package/src/shadow-cascades.ts +97 -0
  140. package/src/vmd-loader.ts +2 -2
package/README.md CHANGED
@@ -2,7 +2,17 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/reze-engine)](https://www.npmjs.com/package/reze-engine)
4
4
 
5
- **Zero-runtime-dependency** WebGPU engine for real-time MMD/PMX rendering — renderer, animation, IK, and physics, all in TypeScript.
5
+ **Zero-runtime-dependency** WebGPU engine for real-time MMD/PMX rendering — renderer, animation, IK, physics, and a multi-effect VFX system, all in TypeScript.
6
+
7
+ One piece of the **Reze MMD family**, covering the whole MMD workflow on the web:
8
+
9
+ | | |
10
+ | ------------------------------------------------------- | ------------------------------------------------------------------------------ |
11
+ | **reze-engine** | This repo — the WebGPU foundation, anime-character rendering and physics |
12
+ | [reze-design](https://github.com/AmyangXYZ/reze-design) | Scene design, rendering and sharing |
13
+ | [reze-studio](https://github.com/AmyangXYZ/reze-studio) | Animation editing on a professional timeline and curve editor |
14
+ | [MiKaPo](https://github.com/AmyangXYZ/MiKaPo) | Real-time motion capture in the browser, exporting straight to VMD |
15
+ | [reze-rig](https://github.com/AmyangXYZ/reze-rig) | Retarget FBX animations to MMD VMD format, Mixamo and Unity tested |
6
16
 
7
17
  ![screenshot](./screenshot.png)
8
18
 
@@ -12,424 +22,44 @@ npm install reze-engine
12
22
 
13
23
  ## Features
14
24
 
15
- - **Anime-style rendering** — toon-ramp NPR over a Principled GGX BSDF, mixed per material
16
- - **Shader-graph materials** — every look is a Blender-style node graph compiled to WGSL; style groups bind any materials to any graph, fully customizable, and PMX sphere maps (`.sph`/`.spa`) are a node, so a model's own highlights are available to any graph that wants them
17
- - **HDR pipeline** — bloom, Filmic tone mapping, ASC CDL colour grading, 4× MSAA, Apple-TBDR-friendly targets
18
- - **Depth of field** — a bladed-bokeh gather in the composite pass, focusing automatically on the character's own depth span. It reads the scene pass's existing depth buffer rather than a G-buffer, and while it is off that buffer is never even stored — the feature costs nothing until you turn it on
19
- - **In-house TS physics** — sequential-impulse rigid bodies for PMX rigs with rest-stable implicit spring-dampers, zero dependencies; structure-of-arrays solver, scene gravity and wind, a world floor so hair and hems rest on the ground instead of clipping through it, and per-frame load shedding that holds framerate on weak devices
20
- - **Math library** — the shared Vec3/Quat/Mat4 layer of the Reze family: euler orders, swing-twist, shortest-arc, look-rotation, zero-alloc `*Into` variants
21
- - **VMD animation** — per-channel bezier curves read the way MMD packs them, IK with per-chain enable read from the motion, morphs on a GPU compute path, and VMD export
22
- - **MMD rig semantics** — 付与親 append inheritance for rotation and movement, composed in MMD's order, and 軸制限 fixed-axis bones, so a 腕捩 twist bone turns along the arm instead of bending it
23
- - **PMX morphs** — vertex, group, bone and material morphs, the last carrying MMD's multiply/add blend so an author's colour and on/off switches work; `getSupportedMorphIndices()` reports what the renderer can actually drive, so a UI never offers a dead control
24
- - **Stage models** — a PMX added as environment rather than cast: no physics, no IK, no outline hulls, and the pose pass skipped while it is idle. Same materials and style groups as any model, and it suppresses the built-in ground plane it would otherwise z-fight with
25
- - **Clip blending & locomotion** — weighted multi-clip pose blending, crossfades, and a game-style character controller (idle/run/sprint speed blend, authored stop skids, code-driven root motion) — the [demo](https://reze.one) is WASD-playable
26
- - **State machine & clip events** — declarative animation states (clips or delegates like the LocomotionController) with guarded crossfade transitions; time-triggered clip callbacks on any playback path
27
- - **Interactive editing** — GPU picking, transform gizmo, bone/material selection
28
- - **Camera** — orbit, bone-follow, or a driven MMD camera VMD; ground + PCF shadows, multi-model scenes
29
- - **Offline rendering** — frame-accurate stepping (`renderFrame`) at any resolution (`setRenderSize`) for video export; background color, 360° equirect backdrop, ground shadow-catcher
30
- - **WGSL scene effects** — a user shader (shadertoy-style) with two mounts, declared by which entry points the code defines: `background` composites between the background and the scene, `foreground` over the finished frame and is handed the scene's depth, so rain and petals are occluded by the character they pass behind and fog thickens with distance. Compile diagnostics and live-tweakable uniform params on both
31
- - **Effects that read the cast** — an effect declares the BONES it wants (`// @anchor 左手首 trail`) and gets their world position, velocity and facing, plus each character's floor point, hip and bounding sphere. `trail` additionally keeps that bone's recent PATH, sampled on the scene clock so a ribbon is identical in the editor, in an export and in a re-export. This is what a hand trail, a halo parented to a head, or a mark under a planted foot is made of
32
-
33
- See [Physics](#physics) and [Rendering](#rendering) for the internals.
34
-
35
- ## Used by
36
-
37
- - [Reze Design](https://reze.design) (web-native scene composer & shader-graph styling)
38
- - [Reze Studio](https://reze.studio) (MMD animation editor)
39
- - [MiKaPo](https://mikapo.reze.one) (motion capture)
40
- - [Reze Rig](https://rig.reze.one) (FBX→VMD retarget)
41
- - [Popo](https://popo.love) (LLM-generated poses)
42
- - [MPL](https://mmd-mpl.vercel.app) (motion language)
43
-
44
- ## Quick start
45
-
46
- ```javascript
47
- import { Engine } from "reze-engine";
48
-
49
- const engine = new Engine(canvas);
50
- await engine.init();
51
-
52
- const model = await engine.loadModel("reze", "/models/reze/reze.pmx");
53
- await engine.autoStyleGroups("reze");
54
-
55
- await model.loadVmd("idle", "/animations/idle.vmd");
56
- model.show("idle");
57
- model.play();
58
-
59
- engine.addGround();
60
- engine.runRenderLoop();
61
- ```
62
-
63
- ## Codebase map
64
-
65
- ```
66
- engine/src/
67
- engine.ts Engine: device/context, render loop, all passes & pipelines,
68
- per-model GPU resources, picking, gizmo (entry point)
69
- model.ts Model: skeleton, 4-bone skinning, morphs (CPU + GPU compute),
70
- animation state, drives IK; per-frame update()
71
- animation.ts AnimationClip, VMD bezier interpolation, playback/priority
72
- locomotion.ts LocomotionController — idle/run/sprint blended over setBlendPose,
73
- pivot-gated turns, root motion returned for setModelTransform
74
- state-machine.ts AnimationStateMachine — clip/delegate states, guarded crossfade
75
- transitions, exit-time returns
76
- ik-solver.ts MMD-style CCD IK (angle limits, solve-axis specialization)
77
- camera.ts Orbit camera (alpha/beta/radius), bone-follow, mouse/touch
78
- math.ts Vec3 / Quat / Mat4 — euler orders, swing-twist, shortest-arc,
79
- look-rotation (zero-alloc *Into variants for hot paths)
80
- pmx-loader.ts PMX parser: mesh, bones, morphs, rigid bodies, joints
81
- vmd-loader.ts VMD motion parser · vmd-writer.ts VMD export (Shift-JIS)
82
- asset-reader.ts URL + local-folder asset resolution · folder-upload.ts
83
- tga-loader.ts TGA decoder · dds-loader.ts DDS/BC1-3 · psd-loader.ts PSD
84
- composite — the texture formats createImageBitmap can't read
85
- index.ts public exports
86
-
87
- graph/ shader-graph → WGSL compiler — materials as data
88
- schema.ts ShaderGraph / StyleGroup / param types + validation
89
- registry.ts node registry (Blender node → WGSL) + socket conversions
90
- compile.ts validate → prune → toposort → peephole → emit
91
- render-class.ts RenderClass / AlphaMode + the RENDER_CLASSES manifest
92
- slots.ts per-render-class fs() shell (stencil/alpha) around the graph body
93
- presets/ the 9 built-in shader graphs (hair, face, eye, cloth, …)
94
-
95
- physics/ in-house rigid-body solver (~4.2k lines)
96
- physics.ts RezePhysics: bone↔body sync, fixed-step accumulator + interpolation
97
- solver.ts sequential-impulse PGS (joint + contact rows)
98
- contact.ts narrowphase (analytical sphere/box/capsule pairs, incl. box-box) + contact pool
99
- constraint.ts 6DOF spring joints · world.ts step, gravity + wind
100
- body.ts SoA rigid-body store · types.ts
101
-
102
- shaders/
103
- materials/ nodes.ts (Blender-node WGSL library the graph compiler emits into) +
104
- common.ts (bindings, skinning VS, fs() shell)
105
- passes/ shadow, morph (GPU vertex-morph compute), bloom, composite (Filmic),
106
- outline, selection, gizmo, pick, ground, mipmap
107
- ```
108
-
109
- ## API
110
-
111
- One WebGPU **Engine** per page (singleton after `init()`). Models load by URL **or** from a user-selected folder ([below](#local-folder-uploads-browser)).
112
-
113
- ### Engine
114
-
115
- ```javascript
116
- engine.init()
117
- engine.loadModel(name, path) // or ({ files, pmxFile? }) for folder upload
118
- engine.getModel(name) / getModelNames() / removeModel(name)
119
- engine.setModelTransform(name, { position?, rotation?, scale?, visible? }) / getModelTransform(name) // place, scale or hide a model (scale is uniform)
120
-
121
- engine.loadStage(name, { files, pmxFile?, transform? }) // a PMX as ENVIRONMENT, not cast: same geometry and materials (style groups work on it unchanged — that is what makes pure-PMX stages worth supporting), but no physics, no IK, no outline hulls, and the pose pass is skipped while it is idle. A stage is usually the heaviest mesh in the scene and the one that never moves
122
- engine.addStage(model, pmxPath, { name?, transform?, assetReader? }) // the same, from an already-loaded Model
123
- engine.groundIsSuppressed() // true while a stage is present — its own floor and addGround's plane both sit at y=0, so the engine refuses to draw the plane rather than z-fight. addGround's settings are kept and come back when the stage is removed
124
-
125
- engine.autoStyleGroups(name, overrides?) // default style groups by material name
126
- engine.applyStyleGroups(name, groups) / upsertStyleGroup / removeStyleGroup / getStyleGroups
127
- engine.setMaterialVisible(name, material, visible) / toggleMaterialVisible / isMaterialVisible
128
-
129
- engine.setIKEnabled(enabled) // engine-wide OFF for hosts that pose bones themselves; ON hands per-chain state to the clip
130
- engine.setPhysicsEnabled(enabled)
131
- engine.resetPhysics() // re-pose bodies from animation + zero velocities (call if physics explodes)
132
- engine.setGravity(vec3) / getGravity() // scene-wide; default (0, -98, 0) — MMD scale, where a character is ~18 units tall
133
- engine.setWind({ direction, strength, turbulence?, frequency? } | null) / getWind() // scene-wide air; strength is in gravity's units, so 10-30 reads as a breeze through hair and skirt
134
-
135
- engine.setCameraFollow(model, bone?, offset?) / setCameraFollow(null)
136
- engine.setCameraTarget(vec3) / setCameraDistance(d) / setCameraAlpha(a) / setCameraBeta(b)
137
-
138
- engine.loadCameraVmd(url) / loadCameraVmdFromBuffer(buffer) // MMD camera track (dedicated file or a VMD's camera block) drives target/rotation/distance/fov — default-on once loaded
139
- engine.setCameraVmdEnabled(on) / isCameraVmdEnabled() / hasCameraVmd() / clearCameraVmd() // toggle the shot; while it drives, orbit/pan/zoom is inert — toggle off to hand control back
140
- engine.getCameraVmdDuration() // seconds the camera track runs for (its last keyframe), 0 with none loaded — its length is its own and need not match any model's clip
141
-
142
- engine.setWorld({ color?, strength? }) / setSun({ color?, strength?, direction? }) // runtime lighting
143
- engine.setBackgroundColor(color | null) // canvas background (display-space sRGB 0–1, composited post-tonemap so it matches a CSS color of the same value exactly); null = transparent canvas (DOM shows through)
144
- engine.setBackdropEquirect(source | null) // 360° backdrop from an equirect (2:1) image — PhotoDome-style dome at infinity, follows the camera, display-only (no lighting/bloom influence); oversized panoramas auto-downscale to the device texture limit
145
- engine.setEffect(wgsl | null, params?) // the scene's WGSL effect, mounted by which entry points the code defines — either, or both in one file. fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f is a LAYER between the base background (color/equirect/transparent) and the scene; fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f composites over the finished frame, which is where rain, snow, petals and fog live. ray = the pixel's world-space view direction (pans with the orbit, same mapping the skybox samples by), uv = 0..1 bottom-left origin, alpha = how much the layer replaces what is behind it. depth (foreground only) = camera-space metres of whatever the scene drew at that pixel, far plane where it drew nothing — compare a particle's own distance against it and the model occludes it; fog reads it directly, since fog's alpha IS a function of distance. Helpers: bgResolution() = canvas size in px, bgCameraPos() = the camera in world space, bgWorldPos(ray, depth) = the world point the scene drew at this pixel — the depth turned into a PLACE, which is what fog lying on the ground needs (it has to know where the ground is) and what keeps a pattern keyed to world position from swimming as the camera orbits. Compiles async off the hot path; on failure the previous effect is KEPT and {ok, diagnostics, mounts} returns line:col errors rebased to the user's code. Declared params ({name: number | {x,y,z}}) become a params.<name> uniform struct shared by both mounts. A foreground makes the scene pass STORE its depth buffer while installed (it is otherwise discarded into tile memory)
146
- engine.setEffectParam(name, value) // write one declared param — a uniform write, no recompile (live slider tier)
147
-
148
- // ── What an effect can ask about the scene (WGSL, in scope at both mounts) ──
149
- // Named rz*; the older bg* spellings all still resolve and always will, because a
150
- // published scene pins the effect that calls them.
151
- rzResolution() -> vec2f // canvas size in px, for aspect correction
152
- rzCameraPos() -> vec3f // the lens, in world space
153
- rzWorldPos(ray, depth) -> vec3f // the depth turned into a PLACE — what fog lying on the ground needs
154
- rzProject(p) -> vec3f // a world point as the camera sees it: xy the uv it lands on, z its distance along the VIEW AXIS. The exact inverse of the per-pixel ray, so anchored work can be measured in 2D instead of marched in 3D, and z compares directly against depth for occlusion. Negative behind the lens
155
- rzSubjectCount() -> i32 // characters in the scene, up to 4 (stages excluded)
156
- rzSubjectHip(i) -> vec3f // where a character IS — at the HIPS, not on the floor. model.position + センター
157
- rzSubject(i) -> RzSubject // { root, center, bounds, valid } — root is the FLOOR under them (the model's origin, which PMX puts between the feet and which moves with a character placed on a stage), center is the hip, bounds is a generous cull sphere
158
- rzAnchor(subject, slot) -> RzAnchor // { pos, vel, fwd, valid } for the slot-th bone this effect DECLARED. Slots are declaration order; valid is false on a rig that spells the bone differently, which is the normal case
159
- rzTrailCount(subject, slot) -> i32 // path samples available — loop to THIS, never to a constant
160
- rzTrail(subject, slot, i) -> vec4f // sample i of that bone's path: xyz where it was, w how many seconds ago. i = 0 is now, running backwards in time
161
-
162
- // Declared at the top of the effect's own source, like the mounts:
163
- // // @anchor 頭 → slot 0, position only
164
- // // @anchor 左手首 trail → slot 1, position AND its recent path
165
- // Only what a file names is resolved and uploaded, so naming none costs nothing
166
- // and nobody pays for a rig's other five hundred bones. Up to 8 anchors, 4
167
- // subjects, 128 samples (~2.1s at 60Hz) — all MINIMUMS: they can grow without
168
- // breaking a published effect, because effects read through these accessors and
169
- // loop to the count functions rather than indexing the buffer.
170
- engine.getEffectMounts() // { background, foreground } — what the installed effect declared; both false with none set
171
- engine.setColorGrading({ shadows?, midtones?, highlights?, contrast?, saturation? }) // ASC CDL grade on the TONEMAPPED scene: the three tonal colours are display-space sRGB with mid-grey (0.5,0.5,0.5) neutral — direction from neutral is the hue that range is pushed toward, distance is the amount, and going darker/lighter than 0.5 crushes or lifts it (shadows→CDL offset, midtones→power, highlights→slope). contrast pivots on 0.5; saturation is Rec.709. Uniforms-only, no pipeline rebuild — safe to call per frame from a slider. A neutral grade is flagged off and costs nothing per pixel
172
- engine.getColorGrading() // current grade, for serialising into a scene descriptor
173
- engine.addGround(options?) // options include opacity (0–1): fades the SURFACE while the received shadow persists (shadow catcher — models stay grounded on photo backdrops); shadowStrength 0 disables the shadow
174
- engine.runRenderLoop(callback?) / stopRenderLoop()
175
- engine.renderFrame(deltaSeconds) // offline rendering: render one frame advancing EVERY clock (animation, physics, camera VMD) by exactly dt — wall-clock independent; call N times with 1/fps for deterministic video export
176
- engine.setRenderSize(w, h) / setRenderSize(null) // pin render resolution (all targets) independent of the canvas CSS size, e.g. 3840×2160 for export; null returns to CSS-size × devicePixelRatio tracking
177
- engine.getStats() // fps + smoothness metrics (frameTimeMax, fps1PercentLow, jitter)
178
- engine.dispose()
179
- ```
180
-
181
- **Options** — Blender-style scene config: `world` = environment lighting, `sun` = directional lamp (`direction` points from sun into the scene), `camera` = framing (`fov` in radians), `background` = canvas background (display-space sRGB, same semantics as `setBackgroundColor`). Callbacks: `onRaycast`, `onGizmoDrag`. The shadow map is cast from `sun.direction` — the same vector the shader lights with — so shading and cast shadows stay coupled.
182
-
183
- ### Model
184
-
185
- ```javascript
186
- await model.loadVmd(name, url) / model.loadClip(name, clip)
187
- model.show(name)
188
- model.play(name, { priority?, loop? }) // priority: higher wins when clips compete (0 = default)
189
- model.pause() / stop() / seek(time)
190
- model.clearAnimation() // stop + DEACTIVATE the clip (stop() keeps it current for re-play; clear() forgets it, so resetAllBones/Morphs actually shows the bind pose)
191
- model.getAnimationProgress() // { current, duration (s), playing, paused, looping, … }
192
- model.exportVmd(name) // → ArrayBuffer (Shift-JIS bone/morph names)
193
-
194
- model.rotateBones({ 首: quat }, ms?) / moveBones({ センター: vec3 }, ms?)
195
- model.setMorphWeight(name, weight, ms?) // drives vertex, group, bone and material morphs alike
196
- model.getMorphing() // { morphs: [{ name, type, … }] } — PMX type: 0 group, 1 vertex, 2 bone, 3–7 UV, 8 material, 9 flip, 10 impulse
197
- model.getSupportedMorphIndices() // indices this renderer can actually move: vertex, bone, material, and group morphs resolving to one of those. UV morphs are parsed but not yet applied; flip and impulse are PMX 2.1 and unsupported. Filter a morph UI by this so it never shows a control that does nothing
198
- model.resetAllBones() / resetAllMorphs()
199
- model.getBoneWorldPosition(name)
200
-
201
- model.setBlendPose(entries) // pose from N weighted clips {name, time, weight} — the caller owns every clock; rest pose fills a weight sum below 1
202
- model.crossfadeTo(name, seconds, { loop? }) // fade the current clip (or the rest pose) into a target that owns the progress clock
203
- model.setBoneRotationOffset(name, quat) // constant local offset composed after every pose source (the classic heel correction); null clears
204
- ```
205
-
206
- ### Locomotion
207
-
208
- ```javascript
209
- const walk = new LocomotionController(model, { idle, run, sprint }, { runSpeed, sprintSpeed })
210
- walk.setMove(x, y, sprint?) // world-vector input: turns toward it (pivots in place past 45°), then runs
211
- walk.setDrive(forward, steer, sprint?) // tank-style alternative: steer rotates the facing, forward runs along it
212
- const pose = walk.update(dt) // per frame: blends the pose, integrates root motion
213
- engine.setModelTransform(name, { position: pose.position, rotation: pose.rotation })
214
- ```
215
-
216
- Clips are in-place; run and sprint share one gait phase so blends stay on the same feet. Match `runSpeed`/`sprintSpeed` to the clips' authored root motion or the feet slide.
217
-
218
- `AnimationClip` holds keyframes only (bone/morph tracks keyed by `frame`, plus `frameCount`); time advances at fixed `FPS` (exported, default 30).
219
-
220
- ### State machine & clip events
221
-
222
- ```javascript
223
- const loco = new LocomotionController(model, clips, { autoApply: false }) // computes but does not apply
224
- const sm = new AnimationStateMachine(model, {
225
- loco: { entries: (dt) => { loco.update(dt); return loco.getBlendEntries() } },
226
- skill: { clip: "Skill_A", loop: false },
227
- }, [
228
- { from: "loco", to: "skill", when: () => wantSkill },
229
- { from: "skill", to: "loco" }, // unconditional: fires as the clip ends
230
- ], { initial: "loco" })
231
- sm.update(dt) // per frame; crossfades keep the outgoing state advancing
232
-
233
- model.addClipEvent("Skill_A", 0.42, (e) => sfx()) // fires when playback crosses 0.42s on any path; returns unsubscribe
234
- ```
235
-
236
- ### Math
25
+ **MMD fidelity**
237
26
 
238
- Every operation has an allocating form and a zero-alloc `*Into` form (out parameter last, returned).
27
+ - PMX models, VMD motion with MMD's own bezier packing, IK with per-chain enable, append-inherit bones and fixed-axis twist bones, VMD export
28
+ - Vertex / group / bone / material morphs (multiply and add), vertex morphs on a GPU compute path
29
+ - MMD draw disciplines reproduced: author-order transparency with depth write, per-mesh interleaved outline hulls, the eyes-through-bangs stencil pass, sphere maps as graph nodes
30
+ - In-house sequential-impulse physics for PMX rigs — rigid bodies, joints, deterministic wind, a world floor
31
+ - Stages as environments, not characters: a stage PMX takes the same materials, graphs and style groups a character does, and keeps the bone and material morphs its author rigged for doors, lifts and colour switches — while skipping physics, IK and idle pose work, and owning the floor so the built-in ground steps aside
239
32
 
240
- ```javascript
241
- Quat.fromEulerOrder(x, y, z, order) // intrinsic order string "YXZ" | "ZYX" | … ; toEulerOrder(q, order) inverts
242
- Quat.fromUnitVectors(from, to) // shortest arc
243
- Quat.twistAroundAxis(q, axis) // swing-twist split: q = swing · twist
244
- Quat.lookRotation(forward, up) // +Z forward
245
- Quat.rotateVec(q, v) / rotateVecInv(q, v)
246
- Quat.fromBasis(x, y, z) // rotation taking the standard basis onto x/y/z
247
- Quat.slerp(a, b, t) / nlerp / dot / angleTo / mirrorZ // mirrorZ = RH ↔ LH (with Vec3.mirrorZ)
248
- bezierInterpolate(x1, x2, y1, y2, t) / interpolateControlPoints(cp, t) // VMD 127-space curves
249
- ```
250
-
251
- ### Local folder uploads (browser)
252
-
253
- Feed a `<input type="file" webkitdirectory>` `FileList` (or drag/drop) into the engine; textures resolve relative to the chosen PMX inside that tree.
254
-
255
- > **Gotcha:** copy `input.files` into an array **before** `input.value = ""` — the `FileList` is live and clearing the input empties it.
256
-
257
- `parsePmxFolderInput(fileList)` returns a tagged result; for `single` you get `{ files, pmxFile }` directly, for `multiple` show a picker over `pmxRelativePaths` and resolve with `pmxFileAtRelativePath(files, path)`. Then:
258
-
259
- ```javascript
260
- const picked = parsePmxFolderInput(e.target.files);
261
- e.target.value = "";
262
- if (picked.status === "single")
263
- await engine.loadModel("m", { files: picked.files, pmxFile: picked.pmxFile });
264
- ```
265
-
266
- VMD and other assets still load by URL when the path starts with `/` or `http(s):`; relative paths resolve against the PMX directory.
267
-
268
- **Any `File[]` works, not just folder picks** — files from plain multi-select or drag & drop carry `webkitRelativePath === ""` and key by **filename** instead (which may itself contain a path: hosts that extract a model `.zip` in-app can synthesize `new File(data, "model/tex/body.png")` and paths resolve exactly like a folder pick). Texture paths additionally fall back to **basename matching** — a PMX referencing `tex/body.png` finds a flat `body.png`; the same fallback rescues wrongly-cased directory names.
269
-
270
- ### Texture formats
271
-
272
- PNG, JPEG, WebP and the rest of what the browser decodes, plus three it doesn't:
273
-
274
- | | |
275
- | --- | --- |
276
- | **TGA** | true-colour (16/24/32), grayscale and colour-mapped, raw or RLE — common in PMX packs, especially sphere maps |
277
- | **DDS** | BC1/BC2/BC3 (DXT1/3/5) and uncompressed BGRA/RGBA/24-bit, including `DX10` headers — what a stage converted out of a game usually carries |
278
- | **PSD** | the flattened composite of a PSD or PSB: RGB, grayscale, indexed and duotone, 8 or 16-bit, raw or RLE — texture packs are often shipped as the artist's working files |
279
-
280
- DDS and PSD are recognised by their **magic bytes rather than their extension**, because in these packs the extension is frequently wrong — a `.tga` that is really a DDS, a `.png` that never stopped being a Photoshop file. All three decode on the CPU to RGBA8, so DDS needs no `texture-compression-bc` device feature and works where that is unavailable.
281
-
282
- A texture that cannot be decoded is logged and skipped, and its material falls back to white — a model never fails to load over one bad file. Two known limits: a PSD saved with *Maximize Compatibility* off carries no usable composite, and CMYK/Lab PSDs are refused rather than converted without a profile.
283
-
284
- ### Interactive pose editing
285
-
286
- Double-click picks a bone or material (per-triangle dominant-joint from the GPU pick, so one handler serves both modes); a local-axis transform gizmo drags it. **The engine only reports — it never writes the skeleton itself**, so the host chooses the write policy.
287
-
288
- ```typescript
289
- engine.setSelectedBone(modelName | null, boneName | null) // shows the gizmo
290
- engine.setSelectedMaterial(modelName | null, materialName | null) // selection outline
291
-
292
- onRaycast: (modelName, material, bone, screenX, screenY) => { ... } // modelName "" = missed
293
-
294
- type GizmoDragEvent = {
295
- boneName: string; boneIndex: number; kind: "rotate" | "translate"
296
- localRotation: Quat; localTranslation: Vec3 // target absolute local transform
297
- phase?: "start" | "end" // undefined during drag moves
298
- }
299
- ```
300
-
301
- The gizmo consumes mouse input inside its bounding sphere so drags never fight camera orbit. Apply the reported transform — runtime override (below) or keyframe edit into a clip you re-`loadClip`:
302
-
303
- ```javascript
304
- onGizmoDrag: (e) => {
305
- const model = engine.getModel(e.modelName);
306
- if (!model) return;
307
- if (e.phase === "start") {
308
- model.pause();
309
- model.setClipApplySuspended(true);
310
- return;
311
- } // stop re-sampling wiping the edit
312
- if (e.phase === "end") return;
313
- if (e.kind === "rotate")
314
- model.rotateBones({ [e.boneName]: e.localRotation }, 0); // 0 = instant write
315
- else model.setBoneLocalTranslation(e.boneIndex, e.localTranslation);
316
- };
317
- // play()/seek() auto-clear the suspend flag (edit is lost — runtime-override semantic).
318
- ```
319
-
320
- Note the asymmetry: rotation goes through `rotateBones(…, 0)`, but translation uses `setBoneLocalTranslation(idx, v)` — `moveBones` converts VMD-relative→local, and the gizmo output is already local.
33
+ **Rendering**
321
34
 
322
- ## Shader graphs & style groups
35
+ - Shader-graph materials: every look is a Blender-style node graph compiled to WGSL; style groups bind any materials to any graph; ungrouped materials render a Principled BSDF default
36
+ - HDR pipeline end to end — bloom, 4× MSAA, three view transforms (Filmic, Standard, AgX from Blender's own LUT), bladed-bokeh depth of field
37
+ - Colour grading over the tonemapped image: ASC CDL slope / offset / power with contrast and saturation, uniforms-only so dragging a slider rebuilds no pipeline — and the background layer stays ungraded, so a grade shapes the scene without staining the sky behind it
38
+ - Two concentric shadow cascades: crisp contact shadows on the cast, coverage for a full stage
39
+ - Floor mirror: a planar reflection pass reusing the scene pipelines, with depth-proportional blur and the reflection composited as its own ground layer
40
+ - HDRI worlds (`.hdr`): the sky renders through the same view transform as the scene and lights the cast via spherical-harmonic irradiance — the sun keeps the toon ramp
41
+ - Positional lights, placed in the scene or emitted by effect shaders — stage rigs, firework bursts, a hand ribbon lighting the dancer as it passes
323
42
 
324
- Materials are styled by **shader graphs** — plain JSON (`ShaderGraph`) validated and compiled to WGSL at runtime. Node semantics track **Blender 5.2**: Principled carries its v2 sockets, and the math (39), vector-math (24) and mix (20) operation sets are Blender's own, transcribed from its GLSL with the safeguards intact (divide-by-zero is 0, modulo truncates), so community Blender NPR presets port by transcription. Sockets match Blender's too — every math node carries three value inputs whichever operation is selected, so a transcription maps socket for socket. One node has no Blender counterpart because MMD has no equivalent to borrow: **`sphere_map`** applies the PMX material's own sphere texture, which is where a PMX keeps its highlights — every model ships one, and hair without it reads flat. The texture is a view-space lighting mask sampled by the camera-space normal rather than by any UV the mesh carries, which is why it tracks the viewer. The material's own mode picks the operator (`.sph` multiplies the shaded base, `.spa` adds), so a graph asks for the effect and the model decides which it meant; a material with no sphere texture is an exact no-op. Three nodes have no Blender counterpart in the same sense, because they answer questions a `.blend` answers with scene objects: **`light`** exposes the key light as values (direction, colour, ambient, shadow), which is what a graph needs to build its own `dot(N, L)` term rather than accept a baked closure — the idiom most game NPR presets use, where the original reads a light empty through a driver. **`head_basis`** gives the head bone's forward/right/up, for face shading that tracks the head. **`tex_image/0`…`3`** read up to four image maps carried by the style group.
43
+ **Effects**
325
44
 
326
- Nine graphs ship built-in (`FACE_GRAPH`, `HAIR_GRAPH`, `BODY_GRAPH`, `EYE_GRAPH`, `METAL_GRAPH`, `STOCKINGS_GRAPH`, `CLOTH_SMOOTH_GRAPH`, `CLOTH_ROUGH_GRAPH`, `DEFAULT_GRAPH` the neutral base) as a starter library; you can also author or import your own.
327
-
328
- A **style group** binds `{ materials, graph, renderClass?, alphaMode? }` — a set of materials, the graph that shades them, and the engine's small pass-integration vocabulary (`renderClass`: `auto`/`eye`/`hair` for stencil/cull/draw-order; `alphaMode`: `opaque`/`hashed`). **Groups are user-defined and unlimited** — any materials, any graph. A graph is pure shading; `renderClass` carries the built-in effects (hair's over-eyes stencil, the eye see-through stamp), so any graph in an `eye`/`hair` group inherits them. **Every group needs a valid graph**; a material in no group renders the **neutral default** (`DEFAULT_GRAPH`).
329
-
330
- Two ways to make groups:
331
-
332
- ```javascript
333
- import { HAIR_GRAPH, compileGraph } from "reze-engine";
334
-
335
- // 1. autoStyleGroups — one default group per matched category (its shipped graph). Easy path.
336
- await engine.autoStyleGroups("reze");
337
-
338
- // 2. applyStyleGroups — arbitrary groups: any id, any materials, any graph.
339
- await engine.applyStyleGroups("reze", [
340
- {
341
- id: "hair",
342
- materials: ["髪", "前髪"],
343
- graph: HAIR_GRAPH,
344
- renderClass: "hair",
345
- },
346
- { id: "visor", materials: ["visor", "hud"], graph: myCustomGraph }, // your own graph
347
- ]);
348
- engine.setStyleParam("reze", "hair", "rim", 0.8); // exposed param → instant uniform write
349
- engine.removeStyleGroup("reze", "hair"); // its materials drop to the neutral default
350
-
351
- // Headless (no GPU needed):
352
- const { ok, wgsl, diagnostics } = compileGraph(HAIR_GRAPH, {
353
- renderClass: "hair",
354
- });
355
- ```
356
-
357
- **How `autoStyleGroups(model, overrides?)` resolves** — it assigns each material a _style category_, then buckets materials by category into one group each:
358
-
359
- 1. **`overrides` first** — an explicit `{ category: [materialNames] }` map (the arg). Use it for the names the built-in hints can't read.
360
- 2. **Then built-in name hints** — a case-insensitive **substring** match of the material name against per-category JP/CN/EN keyword lists, ordered **most-specific-first** so families don't collide (`靴下`/`stocking` resolves to `stockings` before `靴`/`shoes` would hit `cloth_smooth`). This covers standard-named models with no overrides at all.
361
- 3. **No match → ungrouped** — the material renders the neutral default. "Unmatched" is a real, intended outcome, not a catch-all bucket.
362
-
363
- The **built-in name hints**, checked top-to-bottom (first match wins), with the graph and pass-integration each category carries:
364
-
365
- | Category | Graph · render-class / alpha | Matches a name containing (case-insensitive substring) |
366
- | -------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
367
- | `stockings` | `STOCKINGS_GRAPH` · `hashed` alpha | 靴下 · ソックス · タイツ · ニーソ · 袜 · stocking · socks · tights |
368
- | `eye` | `EYE_GRAPH` · `eye` | 白目 · 目影 · 二重 · 睫 · まつげ · まゆ · 眉 · 目 · 瞳 · 眼 · eye · iris · pupil · lash · brow |
369
- | `face` | `FACE_GRAPH` | 顔 · 颜 · 脸 · かお · face · 舌 · tongue · 牙 · 牙齿 · 歯 · teeth · tooth · 口腔 · 口内 · mouth · 嘴 · 歯茎 · gums |
370
- | `hair` | `HAIR_GRAPH` · `hair` | 前髪 · 後髪 · 髪 · 髮 · 头发 · 頭髪 · もみあげ · アホ毛 · ヘア · hair · ahoge · bang |
371
- | `body` | `BODY_GRAPH` | 肌 · 皮肤 · skin |
372
- | `metal` | `METAL_GRAPH` | 金属 · メタル · metal · earring · 耳环 · 耳環 |
373
- | `cloth_smooth` | `CLOTH_SMOOTH_GRAPH` | 服 · 衣 · 裙 · 裤 · スカート · ワンピ · リボン · 袖 · 靴 · 鞋 · 帽 · 体 · 飾 · 饰 · 尾 · 套 · 腿 · skirt · dress · ribbon · sleeve · shoes · shirt · short · boot · hat · cloth · accessor · trigger |
374
-
375
- `cloth_rough` and `default` have **no** name hints — a material reaches them only via an explicit `overrides` entry. The group `id` is the category name, so re-running `autoStyleGroups` is idempotent; its promise resolves after every graph compiles, so `getStyleGroups(model)` is ready the moment it resolves — seed your own store from it, then edit with `applyStyleGroups`.
376
-
377
- Validation catches material conflicts, type mismatches, cycles, and bad links with node-level diagnostics; a failed compile keeps the previous pipeline rendering (fallback-on-error).
378
-
379
- ## Physics
380
-
381
- In-house sequential-impulse rigid-body solver for PMX rigs (sphere / box / capsule colliders, 6DOF spring joints), ~4.2k lines of TypeScript, no external dependency, at quality comparable to Bullet's defaults. A fixed-timestep accumulator runs at a constant **60 Hz** (≤6 substeps/frame) so spring impulse, damping, and integration stay deterministic; dynamic bodies are **render-interpolated** between substeps to stay smooth when the display rate ≠ 60 Hz.
382
-
383
- **Per substep:** `predict velocities → broad + narrowphase → solve constraints (10 iters) → split-impulse position correction → integrate`.
384
-
385
- - **Solver** — projected Gauss-Seidel, joint rows + contact rows in one loop. Joints are 6DOF spring constraints (3 linear + 3 angular) with stop-ERP limit correction. Linear rows pivot on each body's own joint-frame origin (Spring2-style, not Bullet 2.7x's shared mid-anchor), so a violated joint pulls itself back together instead of degenerating into torque and "breaking".
386
- - **Implicit spring-dampers** — each sprung axis solves the backward-Euler soft constraint `relVel⁺ + (k/γ)·err + s·λ = 0` (`γ = c + h·k`, `s = 1/(h·γ)`), unconditionally stable for any authored stiffness, with damping `c = 2ζ√(k·m_eff)` intrinsic to the row. Resting cloth genuinely settles: the previous clamped velocity-drive could inject energy far from equilibrium but not absorb it near it, so static dresses slowly "boiled" — measured 15× lower resting velocity after the change, with authored stiffness now delivered in full (no deadbeat clamp).
387
- - **Angular limits** — hybrid: small violations (< 0.5 rad, the resting-cloth regime) use per-axis Euler stop rows, which converge cleanly and keep resting cloth still; larger violations switch to a single geodesic row toward the Euler-clamped target rotation, because per-axis Euler rows chase phantom errors near the ±90° singularity and pump energy. Ranged stops are unilateral (accumulated impulse clamped to the corrective sign) so a limit pushes back into range but never brakes natural recovery; locked axes stay bilateral equality joints. Spring rows stay per-axis, with stiffness clamped to the `k·dt² ≤ ¼` stability bound.
388
- - **Narrowphase** — analytical sphere-sphere / -capsule / -box, capsule-capsule / -box, and box-box (SAT + face clipping, up to 4 points per manifold). MMD dress rigs are built from box panels, and box-box is the majority of collidable pairs on those models — without it cloth passes through cloth. Capsule-capsule emits multiple contacts along near-parallel axes so cloth can't pivot around a single closest point.
389
- - **Penetration and separation are one term**, ported from Bullet 2.75 (`m_erp` 0.2), the build MMD's own physics runs: the contact row's target carries `depth · erp / dt`. Penetrating, it pushes apart; separated — a speculative row inside the `0.04` margin — it *allows* the approach that closes the gap, which is what keeps those rows inert until the body would really arrive. A push-only clamp does not achieve that on its own: it forbids a pulling impulse, not a large pushing one on a body still in mid-air.
390
- - **Warm starting** (`0.85`) over persistent per-pair manifolds, up to 4 points matched in each body's own local frame, so a resting stack starts each substep already holding roughly the load it needs. When a 5th point arrives the one dropped is whichever leaves the largest quadrilateral — area is what stops a resting box pivoting.
391
- - **Contact ordering is reshuffled between iterations.** Projected Gauss-Seidel is order-biased, rows solved first win, and on a cross-linked skirt lattice that reads as chatter. The shuffle is re-seeded per substep, so a scene still replays identically.
392
- - **Split impulse** resolves deep overlap on a pseudo-velocity channel with its own full iteration pass, integrated straight into the transform. Recovering penetration therefore adds no real momentum for the joint springs to hand back — the property MMD depends on, given how constantly its hair and layered skirts clip.
393
- - **Kinematic advancement** — bone-driven bodies move toward the frame's bone pose incrementally per substep, with velocities derived over the fixed step, so the solver never sees more than one 60 Hz step of anchor motion regardless of render dt.
394
- - **Discontinuity guards** — a bone-pose jump beyond continuous motion (timeline scrub, long stall) rigidly carries each dynamic body along with its kinematic root's transform delta and zeroes momentum instead of dragging cloth across the gap; correction velocities are clamped (120 u/s linear, 30 rad/s angular), per-step travel is capped, and any body that still goes non-finite is restored to its previous substep pose.
395
- - Sleeping is off (cloth must always react); resting bodies bleed micro-velocity via per-PMX damping.
396
-
397
- - **Gravity and wind** are scene-wide and summed once per substep, so wind costs the per-body loop nothing. Wind is an acceleration rather than a drag model: PMX authors already tune per-body damping to get the hang they want, and a second hidden drag term would fight it. Gusting rides a pair of incommensurate sines — one alone is a metronome — and advances on *simulated* time, so an exported take gusts frame-for-frame as its preview did.
398
-
399
- Engine surface is `setPhysicsEnabled` / `resetPhysics` / `setGravity` / `setWind` — everything else (mass, damping, friction, restitution, joint stiffness/limits, collision groups) lives on the PMX rig.
400
-
401
- ## Rendering
402
-
403
- Each built-in shader graph mixes an NPR stack with a Principled-style BSDF, so characters keep a flat illustrated look while highlights and reflections stay grounded. A graph compiles to a fragment shader following one 7-stage layout (node primitives from `nodes.ts`, the fs() shell from `common.ts`):
404
-
405
- ```
406
- (A) setup → (B) texture + alpha → (C) NPR stack → (D) optional bump
407
- → (E) Principled BSDF → (F) NPR↔PBR mix → (G) FSOut
408
- ```
45
+ - N simultaneous scene effects, each one WGSL file declaring its mounts: fullscreen background/foreground, GPU particles, bone-trail ribbons, a persistent simulation grid, and lights
46
+ - Effects read the scene through data interfaces — the cast (bones, velocities, trail history), the audio analysis, MIDI notes, `.lrc` lyrics with their words rasterised for the shader to draw, and per-pixel object/material ids
47
+ - Particles and ribbons are scene geometry: depth-tested, bloomed, and reflected in the mirror
48
+ - Install-time compile diagnostics per effect; a broken effect fails alone
409
49
 
410
- `default` uses only A/B/E/G; the NPR graphs add C (and sometimes D), with F choosing how NPR-leaning the result is.
50
+ **Pipeline**
411
51
 
412
- - **PBR core** (`eval_principled`) GGX + Schlick Fresnel, Walter–Smith G1, Fdez-Agüera 2019 multi-scatter, Karis split-sum DFG LUT, Heitz 2016 LTC direct-spec, optional sheen.
413
- - **NPR toolbox** toon ramps (constant / fwidth-AA'd), HSV warm-shadow / cool-light remaps, fresnel + layer-weight rims, value-noise bump, Voronoi metallic sparkle, BT.601-gated emission.
52
+ - GPU frustum culling writing indirect draw arguments, replayed through render bundles the CPU re-encodes only when scene structure changes
53
+ - Deterministic by construction: everything runs on the scene clock, so offline export (`renderFrame` at any resolution) reproduces the live scene exactly
54
+ - Camera: orbit, bone-follow, or a driven MMD camera VMD; GPU picking, gizmos, morph/material editing surfaces
55
+ - Every emitted shader is compiled on a real GPU device by the repo's validation tool (`engine/tools/validate-wgsl.mjs`) before release
414
56
 
415
- | Built-in graph | Notes |
416
- | -------------- | ----------------------------------------------------------------------- |
417
- | `default` | Plain Principled, metallic 0, rough 0.5 |
418
- | `eye` | Plain + post-eval emission ×1.5 |
419
- | `face` | Toon + warm rim + dual-fresnel rim + bright-tex gate, noise bump |
420
- | `body` | Toon + warm rim + fresnel + facing rim, noise bump |
421
- | `hair` | Toon + fresnel + bevel + bright-tex gate, 20% PBR |
422
- | `cloth_smooth` | Toon + bevel + emission overlay (×18) |
423
- | `cloth_rough` | Same NPR, live noise bump, rough 0.82 |
424
- | `metal` | Toon + emission overlay (×8), Voronoi base, metallic 1 |
425
- | `stockings` | Gradient × facing mask + HSV emission (×5), sheen 0.7, **alpha-hashed** |
57
+ ## Architecture
426
58
 
427
- **Post & output.** Directional shadow map (4096², depth32float, PCF) HDR main pass at MSAA (`rg11b10ufloat` color + `rg8unorm` aux MRT for bloom mask + alpha; fits Apple-Silicon TBDR tile memory so MSAA resolves in-tile, `rgba16float` fallback) → bloom mip pyramid → view transform (`setViewTransformOptions`: `standard` — sRGB and nothing else, which is what NPR and anime work renders under; `filmic` Blender 3.6 "Filmic / Medium High Contrast" as a LUT, the default; `agx` Blender's own 57³ `AgX_Base_sRGB.cube`, plus exposure and gamma) → ASC CDL colour grade (`setColorGrading`, scene only the background layer and any green-screen key are deliberately left ungraded) → composite over the background (base color / 360 equirect, then the optional user WGSL effect layer over-composited by its alpha, display space) → inverted-hull outline.
59
+ One frame, top to bottom. The CPU describes the scene once and the GPU runs it: culling and effect simulation in compute, shadows and the floor mirror feeding a scene pass that draws MMD the way MMD artists expectauthor order, outlines, the eye/hair stencil then bloom, film tone mapping, and depth of field finish the image.
428
60
 
429
- - **Alpha-hashed transparency** (`stockings`) — Wyman & McGuire 2017 derivative-aware stochastic discard in object space, so self-overlapping meshes resolve under MSAA with opaque depth writes and the dither doesn't swim.
430
- - **Sheer-material detection** — PMX has no "translucent" flag (a see-through veil usually ships diffuse alpha 1.0 with the transparency in its texture), so at load each material samples its texture's alpha at its own triangle centroids; genuinely sheer materials route to the transparent bucket — drawn after the opaque + hair passes so a veil composites over the hair behind it, and excluded from the shadow map so sheer cloth doesn't cast the solid shadow of an opaque sheet. Centroids, not vertices: hair-card corners sit in transparent texture margins, and hair must stay opaque-bucket for stencil interplay and shadows.
431
- - **See-through hair over eyes** — stencil-gated extra pass: the eye stamps `EYE_VALUE`, main hair skips it, an extra pass matches it and blends hair at 25% in linear HDR so eyes stay readable.
61
+ ![architecture](./arch.svg)
432
62
 
433
- ## Tutorial
63
+ ## License
434
64
 
435
- [How to Render an Anime Character with WebGPU](https://reze.one/tutorial)
65
+ MIT
package/dist/camera.d.ts CHANGED
@@ -13,6 +13,9 @@ export declare class Camera {
13
13
  * closer than any real framing — and buys 10× precision at character range. */
14
14
  near: number;
15
15
  far: number;
16
+ /** Set once by the engine, from whether it got a float depth format. Decides
17
+ * both the near-plane floor and which way round the projection maps z. */
18
+ reversedZ: boolean;
16
19
  private canvas;
17
20
  private inputLocked;
18
21
  private isDragging;
@@ -1 +1 @@
1
- {"version":3,"file":"camera.d.ts","sourceRoot":"","sources":["../src/camera.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAQ,IAAI,EAAE,MAAM,QAAQ,CAAA;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAWpD,qBAAa,MAAM;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,IAAI,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,EAAE,MAAM,CAAI;IAClB;;;oFAGgF;IAChF,IAAI,EAAE,MAAM,CAAM;IAClB,GAAG,EAAE,MAAM,CAAU;IAGrB,OAAO,CAAC,MAAM,CAAiC;IAC/C,OAAO,CAAC,WAAW,CAAiB;IACpC,OAAO,CAAC,UAAU,CAAiB;IACnC,OAAO,CAAC,WAAW,CAAsB;IACzC,OAAO,CAAC,YAAY,CAAiB;IACrC,OAAO,CAAC,YAAY,CAAiB;IACrC,OAAO,CAAC,eAAe,CAAsB;IAC7C,OAAO,CAAC,UAAU,CAAiB;IACnC,OAAO,CAAC,iBAAiB,CAAY;IACrC,OAAO,CAAC,iBAAiB,CAAiB;IAC1C,OAAO,CAAC,oBAAoB,CAAY;IAGxC,kBAAkB,EAAE,MAAM,CAAQ;IAClC,cAAc,EAAE,MAAM,CAAS;IAC/B,cAAc,EAAE,MAAM,CAAO;IAC7B,cAAc,EAAE,MAAM,CAAO;IAC7B,IAAI,EAAE,MAAM,CAAO;IACnB,IAAI,EAAE,MAAM,CAAU;IACtB,cAAc,EAAE,MAAM,CAAQ;IAC9B,cAAc,EAAE,MAAM,CAAkB;IAGxC,OAAO,CAAC,QAAQ,CAAiC;IACjD,OAAO,CAAC,QAAQ,CAAiC;IAKjD,SAAS,EAAE,OAAO,CAAQ;IAC1B,OAAO,CAAC,UAAU,CAAoB;IACtC,OAAO,CAAC,YAAY,CAAoB;IACxC,OAAO,CAAC,YAAY,CAAM;IAC1B,OAAO,CAAC,SAAS,CAAc;IAC/B,OAAO,CAAC,YAAY,CAAuB;gBAE/B,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,GAAE,MAAoB;IAmBhG,WAAW,IAAI,IAAI;IAQnB;wFACoF;IACpF,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI;IAOpC,0FAA0F;IAC1F,UAAU,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI;IAelC,OAAO,CAAC,MAAM;IAUd;;;;;;;;;;;OAWG;IACH,cAAc,IAAI,IAAI;IAItB,aAAa,IAAI,IAAI;IA2BrB,OAAO,CAAC,gBAAgB;IA0CxB,OAAO,CAAC,SAAS;IAiBjB,kFAAkF;IAClF,OAAO,CAAC,mBAAmB;IAK3B;;;;;;;;;;;;;;;OAeG;IACH,OAAO,CAAC,oBAAoB;IAI5B,mBAAmB,IAAI,IAAI;IAO3B,aAAa,CAAC,MAAM,EAAE,iBAAiB;IAiBvC,aAAa;IAkBb,cAAc,CAAC,MAAM,EAAE,OAAO;IAS9B,OAAO,CAAC,WAAW;IAOnB,OAAO,CAAC,WAAW;IAsBnB,OAAO,CAAC,SAAS;IAKjB,OAAO,CAAC,OAAO;IAYf,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,YAAY;IA8BpB,OAAO,CAAC,WAAW;IAuFnB,OAAO,CAAC,UAAU;CA+BnB"}
1
+ {"version":3,"file":"camera.d.ts","sourceRoot":"","sources":["../src/camera.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAQ,IAAI,EAAE,MAAM,QAAQ,CAAA;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAyBpD,qBAAa,MAAM;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,IAAI,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,EAAE,MAAM,CAAI;IAClB;;;oFAGgF;IAChF,IAAI,EAAE,MAAM,CAAiB;IAC7B,GAAG,EAAE,MAAM,CAAU;IACrB;+EAC2E;IAC3E,SAAS,UAAQ;IAGjB,OAAO,CAAC,MAAM,CAAiC;IAC/C,OAAO,CAAC,WAAW,CAAiB;IACpC,OAAO,CAAC,UAAU,CAAiB;IACnC,OAAO,CAAC,WAAW,CAAsB;IACzC,OAAO,CAAC,YAAY,CAAiB;IACrC,OAAO,CAAC,YAAY,CAAiB;IACrC,OAAO,CAAC,eAAe,CAAsB;IAC7C,OAAO,CAAC,UAAU,CAAiB;IACnC,OAAO,CAAC,iBAAiB,CAAY;IACrC,OAAO,CAAC,iBAAiB,CAAiB;IAC1C,OAAO,CAAC,oBAAoB,CAAY;IAGxC,kBAAkB,EAAE,MAAM,CAAQ;IAClC,cAAc,EAAE,MAAM,CAAS;IAC/B,cAAc,EAAE,MAAM,CAAO;IAC7B,cAAc,EAAE,MAAM,CAAO;IAC7B,IAAI,EAAE,MAAM,CAAO;IACnB,IAAI,EAAE,MAAM,CAAU;IACtB,cAAc,EAAE,MAAM,CAAQ;IAC9B,cAAc,EAAE,MAAM,CAAkB;IAGxC,OAAO,CAAC,QAAQ,CAAiC;IACjD,OAAO,CAAC,QAAQ,CAAiC;IAKjD,SAAS,EAAE,OAAO,CAAQ;IAC1B,OAAO,CAAC,UAAU,CAAoB;IACtC,OAAO,CAAC,YAAY,CAAoB;IACxC,OAAO,CAAC,YAAY,CAAM;IAC1B,OAAO,CAAC,SAAS,CAAc;IAC/B,OAAO,CAAC,YAAY,CAAuB;gBAE/B,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,GAAE,MAAoB;IAmBhG,WAAW,IAAI,IAAI;IAQnB;wFACoF;IACpF,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI;IAOpC,0FAA0F;IAC1F,UAAU,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI;IAelC,OAAO,CAAC,MAAM;IAUd;;;;;;;;;;;OAWG;IACH,cAAc,IAAI,IAAI;IAItB,aAAa,IAAI,IAAI;IA2BrB,OAAO,CAAC,gBAAgB;IA0CxB,OAAO,CAAC,SAAS;IAiBjB,kFAAkF;IAClF,OAAO,CAAC,mBAAmB;IAK3B;;;;;;;;;;;;;;;OAeG;IACH,OAAO,CAAC,oBAAoB;IAM5B,mBAAmB,IAAI,IAAI;IAW3B,aAAa,CAAC,MAAM,EAAE,iBAAiB;IAiBvC,aAAa;IAkBb,cAAc,CAAC,MAAM,EAAE,OAAO;IAS9B,OAAO,CAAC,WAAW;IAOnB,OAAO,CAAC,WAAW;IAsBnB,OAAO,CAAC,SAAS;IAKjB,OAAO,CAAC,OAAO;IAYf,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,YAAY;IA8BpB,OAAO,CAAC,WAAW;IAuFnB,OAAO,CAAC,UAAU;CA+BnB"}
package/dist/camera.js CHANGED
@@ -2,11 +2,25 @@ import { Mat4, Quat, Vec3 } from "./math";
2
2
  /** Far cap / zoom limit; large enough for wide shots without clipping distant ground */
3
3
  const FAR_CAP = 8000;
4
4
  const FAR_MIN = 200;
5
- /** The character-framing value the cloth z-fighting fix settled on — never go below it. */
6
- const NEAR_MIN = 0.5;
7
- /** ~40cm in MMD units. Past this the near plane would start clipping geometry a
8
- * user has deliberately pushed the camera up against. */
9
- const NEAR_MAX = 5;
5
+ /**
6
+ * Near-plane floor and cap, in two sets, because what a near plane costs depends
7
+ * entirely on the depth buffer underneath it.
8
+ *
9
+ * The single set that used to live here was written against a projection that
10
+ * silently doubled it (see Mat4.perspectiveInto), so the real clip was at 1.0 and
11
+ * 10 rather than the 0.5 and 5 stated. UNORM keeps exactly that behaviour, now
12
+ * said out loud: identical precision, identical z-fighting margin on coplanar
13
+ * cloth, no change to any scene that renders correctly today.
14
+ *
15
+ * REVERSED gets the floor the content actually wants. Float depth read backwards
16
+ * holds roughly constant RELATIVE precision across the range, so pulling the near
17
+ * plane in by twenty times costs almost nothing — and a 4 mm near plane is closer
18
+ * than any camera VMD will ever push.
19
+ */
20
+ const NEAR_MIN_UNORM = 1.0;
21
+ const NEAR_MAX_UNORM = 10;
22
+ const NEAR_MIN_REVERSED = 0.05;
23
+ const NEAR_MAX_REVERSED = 5;
10
24
  export class Camera {
11
25
  constructor(alpha, beta, radius, target, fov = Math.PI / 4) {
12
26
  this.aspect = 1;
@@ -14,8 +28,11 @@ export class Camera {
14
28
  * nearly all of it within a hand's reach and coplanar cloth/body layers z-fought
15
29
  * (white crack flashes, worse the further the camera). 0.5 MMD units ≈ 4 cm —
16
30
  * closer than any real framing — and buys 10× precision at character range. */
17
- this.near = 0.5;
31
+ this.near = NEAR_MIN_UNORM;
18
32
  this.far = FAR_CAP;
33
+ /** Set once by the engine, from whether it got a float depth format. Decides
34
+ * both the near-plane floor and which way round the projection maps z. */
35
+ this.reversedZ = false;
19
36
  // Input state
20
37
  this.canvas = null;
21
38
  this.inputLocked = false;
@@ -228,12 +245,20 @@ export class Camera {
228
245
  * user is deliberately close to.
229
246
  */
230
247
  updateNearFromRadius() {
231
- this.near = Math.min(NEAR_MAX, Math.max(NEAR_MIN, this.radius / 50));
248
+ const lo = this.reversedZ ? NEAR_MIN_REVERSED : NEAR_MIN_UNORM;
249
+ const hi = this.reversedZ ? NEAR_MAX_REVERSED : NEAR_MAX_UNORM;
250
+ this.near = Math.min(hi, Math.max(lo, this.radius / 50));
232
251
  }
233
252
  getProjectionMatrix() {
234
253
  this.updateFarFromRadius();
235
254
  this.updateNearFromRadius();
236
- Mat4.perspectiveInto(this._projMat.values, this.fov, this.aspect, this.near, this.far);
255
+ // Reversed-Z is the same matrix with near and far handed over the other way
256
+ // round — near → 1, far → 0. One projection function, and the reversal is a
257
+ // property of the depth buffer rather than a second matrix to keep in step.
258
+ if (this.reversedZ)
259
+ Mat4.perspectiveInto(this._projMat.values, this.fov, this.aspect, this.far, this.near);
260
+ else
261
+ Mat4.perspectiveInto(this._projMat.values, this.fov, this.aspect, this.near, this.far);
237
262
  return this._projMat;
238
263
  }
239
264
  attachControl(canvas) {