reze-engine 0.39.0 → 0.41.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 +8 -5
- package/dist/engine.d.ts +96 -39
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +246 -85
- package/dist/graph/registry.d.ts.map +1 -1
- package/dist/graph/registry.js +10 -0
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/model.d.ts +0 -5
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +0 -36
- package/dist/shaders/materials/nodes.d.ts +1 -1
- package/dist/shaders/materials/nodes.d.ts.map +1 -1
- package/dist/shaders/materials/nodes.js +21 -0
- package/dist/shaders/passes/composite.d.ts +27 -12
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +191 -43
- package/package.json +1 -1
- package/src/engine.ts +286 -93
- package/src/graph/registry.ts +11 -0
- package/src/index.ts +4 -3
- package/src/model.ts +0 -36
- package/src/shaders/materials/nodes.ts +21 -0
- package/src/shaders/passes/composite.ts +218 -59
- package/dist/particles.d.ts +0 -43
- package/dist/particles.d.ts.map +0 -1
- package/dist/particles.js +0 -183
- package/dist/shaders/passes/particles.d.ts +0 -2
- package/dist/shaders/passes/particles.d.ts.map +0 -1
- package/dist/shaders/passes/particles.js +0 -99
- package/src/particles.ts +0 -232
- package/src/shaders/passes/particles.ts +0 -100
package/src/engine.ts
CHANGED
|
@@ -5,7 +5,6 @@ import { MORPH_COMPUTE_WGSL } from "./shaders/passes/morph"
|
|
|
5
5
|
import { decodeTga } from "./tga-loader"
|
|
6
6
|
import { VMDLoader } from "./vmd-loader"
|
|
7
7
|
import { CameraAnimation } from "./camera-animation"
|
|
8
|
-
import { ParticleSystem, type ParticleEmitOptions } from "./particles"
|
|
9
8
|
import { PmxLoader } from "./pmx-loader"
|
|
10
9
|
import { RezePhysics } from "./physics"
|
|
11
10
|
import type { WindOptions } from "./physics/world"
|
|
@@ -236,14 +235,17 @@ export type SunOptions = {
|
|
|
236
235
|
direction?: Vec3
|
|
237
236
|
}
|
|
238
237
|
|
|
239
|
-
/**
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
export type
|
|
243
|
-
export type
|
|
238
|
+
/** An effect param: number → f32, vector-like → vec3f (see setEffect).
|
|
239
|
+
* Structural {x,y,z} rather than the Vec3 class so JSON-derived values (a
|
|
240
|
+
* shared scene document's params) pass straight in. */
|
|
241
|
+
export type EffectParamValue = number | { x: number; y: number; z: number }
|
|
242
|
+
export type EffectResult = {
|
|
244
243
|
ok: boolean
|
|
245
244
|
/** Compile/validation errors, line:col relative to the USER's WGSL. */
|
|
246
245
|
diagnostics: string[]
|
|
246
|
+
/** Which mounts the WGSL declared — `fn background` / `fn foreground`. Both
|
|
247
|
+
* false only on a failed compile, since defining neither IS the failure. */
|
|
248
|
+
mounts: { background: boolean; foreground: boolean }
|
|
247
249
|
}
|
|
248
250
|
|
|
249
251
|
export type CameraOptions = {
|
|
@@ -276,6 +278,42 @@ export const DEFAULT_BLOOM_OPTIONS: BloomOptions = {
|
|
|
276
278
|
clamp: 0.0,
|
|
277
279
|
}
|
|
278
280
|
|
|
281
|
+
/** Camera depth of field — a bokeh gather in the composite pass. Costs nothing
|
|
282
|
+
* while disabled: the scene pass discards its depth buffer and the composite
|
|
283
|
+
* branch never runs. Enabled, the pass stores depth and the gather reads it. */
|
|
284
|
+
export type DepthOfFieldOptions = {
|
|
285
|
+
enabled: boolean
|
|
286
|
+
/** "auto" focuses the first visible character each frame — the camera-space
|
|
287
|
+
* depth span of its bones sets both distance and a floor on range — so a
|
|
288
|
+
* dancer stays sharp without anyone touching a slider. "manual" uses
|
|
289
|
+
* focusDistance/focusRange as given. */
|
|
290
|
+
focusMode: "auto" | "manual"
|
|
291
|
+
/** Camera-space distance to the focus plane (MMD units). */
|
|
292
|
+
focusDistance: number
|
|
293
|
+
/** Depth band that stays perfectly sharp, centered on focusDistance. In auto
|
|
294
|
+
* mode this is a minimum — the band never cuts into the subject. */
|
|
295
|
+
focusRange: number
|
|
296
|
+
/** Blur strength scale; 1 is a natural lens, higher is dreamier. */
|
|
297
|
+
aperture: number
|
|
298
|
+
/** Largest blur circle, in device pixels. */
|
|
299
|
+
maxBlurRadius: number
|
|
300
|
+
/** Bokeh polygon blade count (3–12); 6 is the classic hexagon. */
|
|
301
|
+
bladeCount: number
|
|
302
|
+
/** Gather tap count: 8 / 16 / 24. */
|
|
303
|
+
quality: "performance" | "balanced" | "cinematic"
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export const DEFAULT_DEPTH_OF_FIELD_OPTIONS: DepthOfFieldOptions = {
|
|
307
|
+
enabled: false,
|
|
308
|
+
focusMode: "auto",
|
|
309
|
+
focusDistance: 25,
|
|
310
|
+
focusRange: 2,
|
|
311
|
+
aperture: 1,
|
|
312
|
+
maxBlurRadius: 18,
|
|
313
|
+
bladeCount: 6,
|
|
314
|
+
quality: "balanced",
|
|
315
|
+
}
|
|
316
|
+
|
|
279
317
|
/** Blender Color Management / View (rendering.txt: Filmic, exposure, gamma). `look` is reserved for future curve tweaks. */
|
|
280
318
|
export type ViewTransformOptions = {
|
|
281
319
|
/** Stops applied before Filmic: `linear *= 2^exposure`. */
|
|
@@ -718,8 +756,6 @@ export class Engine {
|
|
|
718
756
|
private multisampleMaskTexture!: GPUTexture
|
|
719
757
|
private maskResolveTexture!: GPUTexture
|
|
720
758
|
private maskResolveView!: GPUTextureView
|
|
721
|
-
/** One-shot particle emissions (emitParticles); lazily built on first emit. */
|
|
722
|
-
private particles: ParticleSystem | null = null
|
|
723
759
|
private renderPassDescriptor!: GPURenderPassDescriptor
|
|
724
760
|
private compositePassDescriptor!: GPURenderPassDescriptor
|
|
725
761
|
// Two specialized composite pipelines via WGSL pipeline-override constants.
|
|
@@ -731,9 +767,18 @@ export class Engine {
|
|
|
731
767
|
private morphComputeBindGroupLayout!: GPUBindGroupLayout
|
|
732
768
|
private compositeBindGroupLayout!: GPUBindGroupLayout
|
|
733
769
|
private compositeBindGroup!: GPUBindGroup
|
|
770
|
+
private depthOfField: DepthOfFieldOptions = { ...DEFAULT_DEPTH_OF_FIELD_OPTIONS }
|
|
771
|
+
private dofUniformBuffer!: GPUBuffer
|
|
772
|
+
private dofUniformData = new Float32Array(12)
|
|
773
|
+
private dofFocusScratch = new Vec3(0, 0, 0)
|
|
774
|
+
/** Depth-only view of the scene's MSAA depth buffer, read by the DoF gather. */
|
|
775
|
+
private depthReadView: GPUTextureView | null = null
|
|
734
776
|
private compositeUniformBuffer!: GPUBuffer
|
|
735
777
|
// [exposure, invGamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
|
|
736
|
-
|
|
778
|
+
// 11 × vec4f — see the viewU comment in composite.ts. The last one is the
|
|
779
|
+
// camera's world position, which is what lets a foreground effect turn the
|
|
780
|
+
// depth it is handed into a PLACE (bgWorldPos) rather than a distance.
|
|
781
|
+
private readonly compositeUniformData = new Float32Array(44)
|
|
737
782
|
/** Composite background (display-space sRGB 0–1) — null = transparent canvas. */
|
|
738
783
|
private backgroundColor: Vec3 | null = null
|
|
739
784
|
// 360 backdrop (equirectangular skybox, sampled by view ray in composite).
|
|
@@ -741,21 +786,27 @@ export class Engine {
|
|
|
741
786
|
private backdropEquirectView: GPUTextureView | null = null
|
|
742
787
|
private fallbackEquirectTexture!: GPUTexture
|
|
743
788
|
private fallbackEquirectView!: GPUTextureView
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
//
|
|
789
|
+
// The scene's user WGSL effect (setEffect). ONE per scene, mounted under the
|
|
790
|
+
// scene, over it, or both — whichever of background()/foreground() the code
|
|
791
|
+
// defines. The composite pipelines are REBUILT with the user code injected;
|
|
792
|
+
// params live in their own uniform buffer so setEffectParam is a write, not a
|
|
747
793
|
// recompile (the same instant tier as setStyleParam).
|
|
748
|
-
private
|
|
794
|
+
private effect: {
|
|
749
795
|
wgsl: string
|
|
750
796
|
paramLayout: Map<string, { offset: number; comps: 1 | 3 }>
|
|
751
797
|
paramsBuffer: GPUBuffer | null
|
|
752
798
|
paramsData: Float32Array<ArrayBuffer>
|
|
799
|
+
/** Mounted under the scene. */
|
|
800
|
+
hasBackground: boolean
|
|
801
|
+
/** Mounted over the finished frame — and the reason the scene pass has to
|
|
802
|
+
* STORE its depth, which it otherwise discards into tile memory. */
|
|
803
|
+
hasForeground: boolean
|
|
753
804
|
} | null = null
|
|
754
805
|
/** Bound at composite binding 7 when no effect (or a param-less one) is set. */
|
|
755
806
|
private bgParamsDummyBuffer!: GPUBuffer
|
|
756
807
|
private compositePipelineLayout!: GPUPipelineLayout
|
|
757
|
-
/** time=0 origin for the active effect — reset each
|
|
758
|
-
private
|
|
808
|
+
/** time=0 origin for the active effect — reset each setEffect. */
|
|
809
|
+
private effectEpochMs = 0
|
|
759
810
|
private compositeBloomView: GPUTextureView | null = null
|
|
760
811
|
|
|
761
812
|
// EEVEE-style bloom pyramid (mirrors Blender 3.6 effect_bloom_frag.glsl):
|
|
@@ -1023,10 +1074,10 @@ export class Engine {
|
|
|
1023
1074
|
u[8] = bg?.x ?? 0
|
|
1024
1075
|
u[9] = bg?.y ?? 0
|
|
1025
1076
|
u[10] = bg?.z ?? 0
|
|
1026
|
-
// Base-layer mode
|
|
1027
|
-
//
|
|
1077
|
+
// Base-layer mode only. A user effect is a separate LAYER over whichever
|
|
1078
|
+
// base is active, and needs no flag of its own: the composite pipeline is
|
|
1079
|
+
// rebuilt per effect, so the compiled variant IS the flag. u[25] is spare.
|
|
1028
1080
|
u[11] = this.backdropEquirectView ? 2 : bg ? 1 : 0
|
|
1029
|
-
u[25] = this.backgroundEffect ? 1 : 0
|
|
1030
1081
|
u[26] = this.canvas.width
|
|
1031
1082
|
u[27] = this.canvas.height
|
|
1032
1083
|
// ── Grade (viewU[7..9]) ── The UI's three tonal COLORS map to ASC CDL here,
|
|
@@ -1082,7 +1133,7 @@ export class Engine {
|
|
|
1082
1133
|
}
|
|
1083
1134
|
|
|
1084
1135
|
private rebuildCompositeBindGroup(): void {
|
|
1085
|
-
if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView) return
|
|
1136
|
+
if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView || !this.depthReadView) return
|
|
1086
1137
|
this.compositeBindGroup = this.device.createBindGroup({
|
|
1087
1138
|
label: "composite bind group",
|
|
1088
1139
|
layout: this.compositeBindGroupLayout,
|
|
@@ -1094,7 +1145,9 @@ export class Engine {
|
|
|
1094
1145
|
{ binding: 4, resource: this.maskResolveView },
|
|
1095
1146
|
{ binding: 5, resource: this.filmicLutView },
|
|
1096
1147
|
{ binding: 6, resource: this.backdropEquirectView ?? this.fallbackEquirectView },
|
|
1097
|
-
{ binding: 7, resource: { buffer: this.
|
|
1148
|
+
{ binding: 7, resource: { buffer: this.effect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
|
|
1149
|
+
{ binding: 8, resource: this.depthReadView },
|
|
1150
|
+
{ binding: 9, resource: { buffer: this.dofUniformBuffer } },
|
|
1098
1151
|
],
|
|
1099
1152
|
})
|
|
1100
1153
|
}
|
|
@@ -1159,45 +1212,72 @@ export class Engine {
|
|
|
1159
1212
|
}
|
|
1160
1213
|
|
|
1161
1214
|
/**
|
|
1162
|
-
* Install
|
|
1163
|
-
*
|
|
1164
|
-
*
|
|
1165
|
-
* or transparency) — its alpha lets the base show through, so a starfield is
|
|
1166
|
-
* stars over the user's background color. Display-space: never affects
|
|
1167
|
-
* lighting, bloom, or tonemapping, and is captured by offline export like any
|
|
1168
|
-
* background.
|
|
1169
|
-
*
|
|
1170
|
-
* `wgsl` must define:
|
|
1215
|
+
* Install the scene's WGSL effect (shadertoy-style), rendered per-pixel in the
|
|
1216
|
+
* composite pass. ONE effect per scene, and the code says where it mounts by
|
|
1217
|
+
* which of these it defines — either, or both in one file:
|
|
1171
1218
|
*
|
|
1172
1219
|
* fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f
|
|
1220
|
+
* fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f
|
|
1173
1221
|
*
|
|
1174
|
-
*
|
|
1175
|
-
*
|
|
1176
|
-
*
|
|
1177
|
-
*
|
|
1178
|
-
*
|
|
1179
|
-
*
|
|
1222
|
+
* `background` is a LAYER between the base background and the scene,
|
|
1223
|
+
* over-composited onto whichever base is active (solid color, 360 equirect, or
|
|
1224
|
+
* transparency) — its alpha lets the base show through, so a starfield is
|
|
1225
|
+
* stars over the user's background color. `foreground` composites over the
|
|
1226
|
+
* finished frame instead, which is where rain, snow, petals and fog live, and
|
|
1227
|
+
* is handed `depth`: the camera-space distance in metres of whatever the scene
|
|
1228
|
+
* drew at that pixel (the far plane where it drew nothing). Compare a
|
|
1229
|
+
* particle's own distance against it and the model occludes it; fog just reads
|
|
1230
|
+
* it, since fog's alpha IS a function of distance.
|
|
1180
1231
|
*
|
|
1181
|
-
*
|
|
1182
|
-
*
|
|
1183
|
-
*
|
|
1232
|
+
* `ray` is the pixel's normalized world-space view direction (LH, +Z forward —
|
|
1233
|
+
* what the skybox samples by), `uv` is 0..1 bottom-left origin, `time` is
|
|
1234
|
+
* seconds since apply, and `bgResolution()` gives the canvas size. Return sRGB
|
|
1235
|
+
* + alpha; alpha is the only "how much does this replace" control there is.
|
|
1236
|
+
* Declared `params` arrive as `params.<name>` (number → f32, Vec3 → vec3f),
|
|
1237
|
+
* shared by both mounts, and are later tweaked without recompiling via
|
|
1238
|
+
* setEffectParam.
|
|
1239
|
+
*
|
|
1240
|
+
* Both mounts are display-space: neither affects lighting, bloom or
|
|
1241
|
+
* tonemapping, and both are captured by offline export. A foreground makes the
|
|
1242
|
+
* scene pass STORE its depth buffer (it otherwise discards it into tile
|
|
1243
|
+
* memory) for as long as one is installed.
|
|
1244
|
+
*
|
|
1245
|
+
* Compiles off the hot path (async pipelines): on failure the previous effect
|
|
1246
|
+
* is KEPT and diagnostics are returned with line numbers relative to the
|
|
1247
|
+
* user's WGSL. Pass null to remove the effect.
|
|
1184
1248
|
*/
|
|
1185
|
-
async
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
): Promise<BackgroundEffectResult> {
|
|
1189
|
-
if (!this.device) return { ok: false, diagnostics: ["setBackgroundEffect requires init() to have run"] }
|
|
1249
|
+
async setEffect(wgsl: string | null, params?: Record<string, EffectParamValue>): Promise<EffectResult> {
|
|
1250
|
+
const noMounts = { background: false, foreground: false }
|
|
1251
|
+
if (!this.device) return { ok: false, diagnostics: ["setEffect requires init() to have run"], mounts: noMounts }
|
|
1190
1252
|
|
|
1191
1253
|
if (wgsl === null) {
|
|
1192
|
-
this.
|
|
1193
|
-
this.
|
|
1254
|
+
this.effect?.paramsBuffer?.destroy()
|
|
1255
|
+
this.effect = null
|
|
1194
1256
|
const module = this.device.createShaderModule({ label: "composite shader", code: buildCompositeShader(null) })
|
|
1195
1257
|
this.compositePipelineIdentity = this.makeCompositePipeline(module, false, "composite pipeline (gamma=1)")
|
|
1196
1258
|
this.compositePipelineGamma = this.makeCompositePipeline(module, true, "composite pipeline (gamma!=1)")
|
|
1197
1259
|
this.rebuildCompositeBindGroup()
|
|
1198
1260
|
this.writeCompositeViewUniforms()
|
|
1199
|
-
return { ok: true, diagnostics: [] }
|
|
1261
|
+
return { ok: true, diagnostics: [], mounts: noMounts }
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// ── Which mounts did the author ask for? A declaration, not a setting: the
|
|
1265
|
+
// entry points present in the source are the ones compiled in. Matching the
|
|
1266
|
+
// `fn` keyword is enough to be safe against a `foreground` LOCAL or a call
|
|
1267
|
+
// to one — those never follow `fn`.
|
|
1268
|
+
const hasBackground = /\bfn\s+background\s*\(/.test(wgsl)
|
|
1269
|
+
const hasForeground = /\bfn\s+foreground\s*\(/.test(wgsl)
|
|
1270
|
+
if (!hasBackground && !hasForeground) {
|
|
1271
|
+
return {
|
|
1272
|
+
ok: false,
|
|
1273
|
+
diagnostics: [
|
|
1274
|
+
"an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f " +
|
|
1275
|
+
"or fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f (or both)",
|
|
1276
|
+
],
|
|
1277
|
+
mounts: noMounts,
|
|
1278
|
+
}
|
|
1200
1279
|
}
|
|
1280
|
+
const mounts = { background: hasBackground, foreground: hasForeground }
|
|
1201
1281
|
|
|
1202
1282
|
// ── Params: codegen a WGSL struct and mirror its uniform layout on the CPU.
|
|
1203
1283
|
// Fields are emitted in declaration order; offsets follow WGSL's natural
|
|
@@ -1209,7 +1289,7 @@ export class Engine {
|
|
|
1209
1289
|
let cursor = 0
|
|
1210
1290
|
for (const [name, value] of entries) {
|
|
1211
1291
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
1212
|
-
return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`] }
|
|
1292
|
+
return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`], mounts }
|
|
1213
1293
|
}
|
|
1214
1294
|
const isVec = typeof value !== "number"
|
|
1215
1295
|
const align = isVec ? 16 : 4
|
|
@@ -1229,22 +1309,22 @@ export class Engine {
|
|
|
1229
1309
|
}
|
|
1230
1310
|
}
|
|
1231
1311
|
const paramsDecl = entries.length
|
|
1232
|
-
? `struct
|
|
1312
|
+
? `struct EffectParams {\n${fields.join("\n")}\n}\n@group(0) @binding(7) var<uniform> params: EffectParams;\n`
|
|
1233
1313
|
: ""
|
|
1234
1314
|
|
|
1235
1315
|
// ── Compile with validation captured, not thrown at the console. Line
|
|
1236
1316
|
// numbers in diagnostics are rebased to the USER's source.
|
|
1237
|
-
const source = buildCompositeShader({ wgsl, paramsDecl })
|
|
1317
|
+
const source = buildCompositeShader({ wgsl, paramsDecl, hasBackground, hasForeground })
|
|
1238
1318
|
const userLineOffset = source.slice(0, source.indexOf(wgsl)).split("\n").length - 1
|
|
1239
1319
|
this.device.pushErrorScope("validation")
|
|
1240
|
-
const module = this.device.createShaderModule({ label: "composite shader (
|
|
1320
|
+
const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source })
|
|
1241
1321
|
const info = await module.getCompilationInfo()
|
|
1242
1322
|
const scopeErr = await this.device.popErrorScope()
|
|
1243
1323
|
const diagnostics = info.messages
|
|
1244
1324
|
.filter((m) => m.type === "error")
|
|
1245
1325
|
.map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`)
|
|
1246
1326
|
if (diagnostics.length === 0 && scopeErr) diagnostics.push(scopeErr.message)
|
|
1247
|
-
if (diagnostics.length > 0) return { ok: false, diagnostics }
|
|
1327
|
+
if (diagnostics.length > 0) return { ok: false, diagnostics, mounts }
|
|
1248
1328
|
let identity: GPURenderPipeline
|
|
1249
1329
|
let gamma: GPURenderPipeline
|
|
1250
1330
|
try {
|
|
@@ -1262,37 +1342,42 @@ export class Engine {
|
|
|
1262
1342
|
primitive: { topology: "triangle-list" },
|
|
1263
1343
|
})
|
|
1264
1344
|
;[identity, gamma] = await Promise.all([
|
|
1265
|
-
make(false, "composite pipeline (
|
|
1266
|
-
make(true, "composite pipeline (
|
|
1345
|
+
make(false, "composite pipeline (effect, gamma=1)"),
|
|
1346
|
+
make(true, "composite pipeline (effect, gamma!=1)"),
|
|
1267
1347
|
])
|
|
1268
1348
|
} catch (e) {
|
|
1269
|
-
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)] }
|
|
1349
|
+
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts }
|
|
1270
1350
|
}
|
|
1271
1351
|
|
|
1272
1352
|
// ── Swap — only now does the old effect (and its params buffer) go away.
|
|
1273
|
-
this.
|
|
1353
|
+
this.effect?.paramsBuffer?.destroy()
|
|
1274
1354
|
let paramsBuffer: GPUBuffer | null = null
|
|
1275
1355
|
if (entries.length) {
|
|
1276
1356
|
paramsBuffer = this.device.createBuffer({
|
|
1277
|
-
label: "
|
|
1357
|
+
label: "effect params",
|
|
1278
1358
|
size: paramsData.byteLength,
|
|
1279
1359
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1280
1360
|
})
|
|
1281
1361
|
this.device.queue.writeBuffer(paramsBuffer, 0, paramsData)
|
|
1282
1362
|
}
|
|
1283
|
-
this.
|
|
1363
|
+
this.effect = { wgsl, paramLayout: layout, paramsBuffer, paramsData, hasBackground, hasForeground }
|
|
1284
1364
|
this.compositePipelineIdentity = identity
|
|
1285
1365
|
this.compositePipelineGamma = gamma
|
|
1286
|
-
this.
|
|
1366
|
+
this.effectEpochMs = performance.now()
|
|
1287
1367
|
this.rebuildCompositeBindGroup()
|
|
1288
1368
|
this.writeCompositeViewUniforms()
|
|
1289
|
-
return { ok: true, diagnostics: [] }
|
|
1369
|
+
return { ok: true, diagnostics: [], mounts }
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/** Which mounts the installed effect declared. Both false when none is set. */
|
|
1373
|
+
getEffectMounts(): { background: boolean; foreground: boolean } {
|
|
1374
|
+
return { background: this.effect?.hasBackground ?? false, foreground: this.effect?.hasForeground ?? false }
|
|
1290
1375
|
}
|
|
1291
1376
|
|
|
1292
|
-
/** Write one
|
|
1293
|
-
*
|
|
1294
|
-
|
|
1295
|
-
const fx = this.
|
|
1377
|
+
/** Write one effect param (declared at setEffect) — a uniform write, no
|
|
1378
|
+
* recompile; the instant tier, like setStyleParam. */
|
|
1379
|
+
setEffectParam(name: string, value: EffectParamValue): void {
|
|
1380
|
+
const fx = this.effect
|
|
1296
1381
|
if (!fx || !fx.paramsBuffer) return
|
|
1297
1382
|
const slot = fx.paramLayout.get(name)
|
|
1298
1383
|
if (!slot) return
|
|
@@ -1306,6 +1391,89 @@ export class Engine {
|
|
|
1306
1391
|
}
|
|
1307
1392
|
|
|
1308
1393
|
/** Patch bloom; GPU uniforms update immediately if `init()` has run. */
|
|
1394
|
+
/** Camera depth of field (see DepthOfFieldOptions). Free while disabled —
|
|
1395
|
+
* the scene pass only stores its depth buffer on frames the gather reads. */
|
|
1396
|
+
setDepthOfField(patch: Partial<DepthOfFieldOptions>): void {
|
|
1397
|
+
this.depthOfField = { ...this.depthOfField, ...patch }
|
|
1398
|
+
if (!this.device || !this.dofUniformBuffer) return
|
|
1399
|
+
if (this.depthOfField.enabled) {
|
|
1400
|
+
this.writeDepthOfFieldUniforms()
|
|
1401
|
+
} else {
|
|
1402
|
+
// One last write so the shader's uniform branch reads a clean zero.
|
|
1403
|
+
this.dofUniformData[0] = 0
|
|
1404
|
+
this.device.queue.writeBuffer(this.dofUniformBuffer, 0, this.dofUniformData)
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
getDepthOfField(): DepthOfFieldOptions {
|
|
1409
|
+
return { ...this.depthOfField }
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/** Auto-focus target: the camera-space depth span of the first visible
|
|
1413
|
+
* character's bones. Focus sits at the span's midpoint; the range covers the
|
|
1414
|
+
* span plus a margin for what bones don't reach (shoes, hair, cloth). */
|
|
1415
|
+
private getModelBodyFocus(): { distance: number; range: number } | null {
|
|
1416
|
+
if (!this.camera) return null
|
|
1417
|
+
const view = this.camera.getViewMatrix().values
|
|
1418
|
+
for (const inst of this.modelInstances.values()) {
|
|
1419
|
+
if (!inst.model.visible || inst.isStage) continue
|
|
1420
|
+
const model = inst.model
|
|
1421
|
+
const matrices = model.getWorldMatrices()
|
|
1422
|
+
if (matrices.length === 0) continue
|
|
1423
|
+
const scale = model.scale
|
|
1424
|
+
const local = this.dofFocusScratch
|
|
1425
|
+
let minDepth = Infinity
|
|
1426
|
+
let maxDepth = -Infinity
|
|
1427
|
+
for (const matrix of matrices) {
|
|
1428
|
+
const values = matrix.values
|
|
1429
|
+
// Bone matrices are model-space; apply the same root transform the
|
|
1430
|
+
// renderer bakes into skinning, then take camera-space z.
|
|
1431
|
+
local.setXYZ(values[12] * scale, values[13] * scale, values[14] * scale)
|
|
1432
|
+
Quat.rotateVecInto(model.rotation, local, local)
|
|
1433
|
+
const x = model.position.x + local.x
|
|
1434
|
+
const y = model.position.y + local.y
|
|
1435
|
+
const z = model.position.z + local.z
|
|
1436
|
+
const depth = view[2] * x + view[6] * y + view[10] * z + view[14]
|
|
1437
|
+
if (!Number.isFinite(depth) || depth <= this.camera.near) continue
|
|
1438
|
+
minDepth = Math.min(minDepth, depth)
|
|
1439
|
+
maxDepth = Math.max(maxDepth, depth)
|
|
1440
|
+
}
|
|
1441
|
+
if (Number.isFinite(minDepth) && Number.isFinite(maxDepth)) {
|
|
1442
|
+
const span = Math.max(0, maxDepth - minDepth)
|
|
1443
|
+
const meshMargin = Math.max(2.0, span * 0.15)
|
|
1444
|
+
return { distance: (minDepth + maxDepth) * 0.5, range: Math.max(2.0, span + meshMargin) }
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
return null
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
private writeDepthOfFieldUniforms(): void {
|
|
1451
|
+
if (!this.device || !this.dofUniformBuffer) return
|
|
1452
|
+
const d = this.depthOfField
|
|
1453
|
+
const u = this.dofUniformData
|
|
1454
|
+
// `d.enabled &&`, because a foreground effect also drives this write (for
|
|
1455
|
+
// projA/projB alone) and auto-focus walks every visible character's bones —
|
|
1456
|
+
// work nothing would read with the gather switched off.
|
|
1457
|
+
const auto = d.enabled && d.focusMode === "auto" ? this.getModelBodyFocus() : null
|
|
1458
|
+
u[0] = d.enabled ? 1 : 0
|
|
1459
|
+
u[1] = auto?.distance ?? Math.max(d.focusDistance, 0.05)
|
|
1460
|
+
// In auto mode the authored range is a floor — the sharp band never cuts
|
|
1461
|
+
// into the character's own depth span.
|
|
1462
|
+
u[2] = Math.max(d.focusRange, auto?.range ?? 0.02, 0.02)
|
|
1463
|
+
u[3] = Math.max(d.aperture, 0)
|
|
1464
|
+
u[4] = Math.max(d.maxBlurRadius, 0)
|
|
1465
|
+
u[5] = Math.min(12, Math.max(3, Math.round(d.bladeCount)))
|
|
1466
|
+
u[6] = d.quality === "performance" ? 8 : d.quality === "cinematic" ? 24 : 16
|
|
1467
|
+
u[7] = 1 // anamorphic ratio, reserved (the shader clamps ≥ 0.25)
|
|
1468
|
+
// viewZ = projB / (z − projA), the inverse of perspectiveInto's z mapping.
|
|
1469
|
+
// near/far track the camera radius, so these refresh every enabled frame.
|
|
1470
|
+
const near = this.camera.near
|
|
1471
|
+
const far = this.camera.far
|
|
1472
|
+
u[8] = (far + near) / (far - near)
|
|
1473
|
+
u[9] = (-2 * near * far) / (far - near)
|
|
1474
|
+
this.device.queue.writeBuffer(this.dofUniformBuffer, 0, u)
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1309
1477
|
setBloomOptions(patch: Partial<BloomOptions>): void {
|
|
1310
1478
|
const b = this.bloomSettings
|
|
1311
1479
|
if (patch.enabled !== undefined) b.enabled = patch.enabled
|
|
@@ -2165,11 +2333,18 @@ export class Engine {
|
|
|
2165
2333
|
// mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
|
|
2166
2334
|
this.compositeUniformBuffer = this.device.createBuffer({
|
|
2167
2335
|
label: "composite view uniforms",
|
|
2168
|
-
//
|
|
2336
|
+
// 11 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
|
|
2169
2337
|
// (bg rgb, mode) · camera right/up/forward basis for the 360 skybox ray ·
|
|
2170
|
-
// (time, _, canvas width, canvas height) for user
|
|
2171
|
-
//
|
|
2172
|
-
|
|
2338
|
+
// (time, _, canvas width, canvas height) for user effects · three grade
|
|
2339
|
+
// vectors (CDL offset+contrast, power+saturation, slope+flag) · camera
|
|
2340
|
+
// world position, for an effect placing itself in the scene.
|
|
2341
|
+
size: 176,
|
|
2342
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
2343
|
+
})
|
|
2344
|
+
this.dofUniformBuffer = this.device.createBuffer({
|
|
2345
|
+
label: "depth of field uniforms",
|
|
2346
|
+
// 3 × vec4f — see the dofU comment in composite.ts.
|
|
2347
|
+
size: 48,
|
|
2173
2348
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
2174
2349
|
})
|
|
2175
2350
|
this.bgParamsDummyBuffer = this.device.createBuffer({
|
|
@@ -2194,6 +2369,15 @@ export class Engine {
|
|
|
2194
2369
|
// User background-effect params — dummy buffer when no effect is set. The
|
|
2195
2370
|
// layout is explicit, so the base shader legally ignores the binding.
|
|
2196
2371
|
{ binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
2372
|
+
// The scene pass's MSAA depth, depth-only aspect — read by the DoF
|
|
2373
|
+
// gather. Contents are undefined while DoF is off (the scene pass
|
|
2374
|
+
// discards depth then), and the shader never reads it then either.
|
|
2375
|
+
{
|
|
2376
|
+
binding: 8,
|
|
2377
|
+
visibility: GPUShaderStage.FRAGMENT,
|
|
2378
|
+
texture: { sampleType: "depth", viewDimension: "2d", multisampled: true },
|
|
2379
|
+
},
|
|
2380
|
+
{ binding: 9, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
2197
2381
|
],
|
|
2198
2382
|
})
|
|
2199
2383
|
this.fallbackEquirectTexture = this.device.createTexture({
|
|
@@ -2439,10 +2623,13 @@ export class Engine {
|
|
|
2439
2623
|
size: [width, height],
|
|
2440
2624
|
sampleCount: Engine.MULTISAMPLE_COUNT,
|
|
2441
2625
|
format: "depth24plus-stencil8",
|
|
2442
|
-
usage
|
|
2626
|
+
// TEXTURE_BINDING for the DoF gather — a usage flag, not a copy; the
|
|
2627
|
+
// zero-cost-when-off story lives in depthStoreOp, not here.
|
|
2628
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
2443
2629
|
})
|
|
2444
2630
|
|
|
2445
2631
|
const depthTextureView = this.depthTexture.createView()
|
|
2632
|
+
this.depthReadView = this.depthTexture.createView({ aspect: "depth-only" })
|
|
2446
2633
|
|
|
2447
2634
|
// storeOp="discard" on MSAA views keeps per-sample data in Apple TBDR tile memory —
|
|
2448
2635
|
// only the resolveTarget (hdrResolveTexture / maskResolveView) gets written to RAM.
|
|
@@ -2871,6 +3058,13 @@ export class Engine {
|
|
|
2871
3058
|
return this.cameraAnimation !== null
|
|
2872
3059
|
}
|
|
2873
3060
|
|
|
3061
|
+
/** Seconds the loaded camera VMD runs for — its last keyframe — or 0 with none
|
|
3062
|
+
* loaded. A timeline cannot draw a lane to scale without it, and the camera's
|
|
3063
|
+
* length is its own: it does not have to match any model's clip. */
|
|
3064
|
+
getCameraVmdDuration(): number {
|
|
3065
|
+
return this.cameraAnimation?.duration ?? 0
|
|
3066
|
+
}
|
|
3067
|
+
|
|
2874
3068
|
/** Drop the loaded camera VMD and return to orbit control. */
|
|
2875
3069
|
clearCameraVmd(): void {
|
|
2876
3070
|
this.cameraAnimation = null
|
|
@@ -2923,24 +3117,6 @@ export class Engine {
|
|
|
2923
3117
|
this.camera.fov = fov
|
|
2924
3118
|
}
|
|
2925
3119
|
|
|
2926
|
-
/** Fire a one-shot particle effect (see ParticleEmitOptions): "burst" scatters
|
|
2927
|
-
* glowing sparks from the given world points, "converge" condenses them onto
|
|
2928
|
-
* the points. Pair with Model.sampleSurfacePoints for dissolve/materialize
|
|
2929
|
-
* transitions shaped like the model. Stateless GPU particles — the emission
|
|
2930
|
-
* cleans itself up when it expires. */
|
|
2931
|
-
emitParticles(opts: ParticleEmitOptions): void {
|
|
2932
|
-
if (!this.device) return
|
|
2933
|
-
if (!this.particles) {
|
|
2934
|
-
this.particles = new ParticleSystem(this.device, this.cameraUniformBuffer, {
|
|
2935
|
-
hdr: this.hdrFormat,
|
|
2936
|
-
mask: Engine.BLOOM_MASK_FORMAT,
|
|
2937
|
-
depth: "depth24plus-stencil8",
|
|
2938
|
-
sampleCount: Engine.MULTISAMPLE_COUNT,
|
|
2939
|
-
})
|
|
2940
|
-
}
|
|
2941
|
-
this.particles.emit(opts, performance.now() / 1000)
|
|
2942
|
-
}
|
|
2943
|
-
|
|
2944
3120
|
// Step 5: Create lighting buffers
|
|
2945
3121
|
private setupLighting() {
|
|
2946
3122
|
this.lightUniformBuffer = this.device.createBuffer({
|
|
@@ -3096,8 +3272,6 @@ export class Engine {
|
|
|
3096
3272
|
this.forEachInstance((inst) => inst.model.stop())
|
|
3097
3273
|
if (Engine.instance === this) Engine.instance = null
|
|
3098
3274
|
if (this.camera) this.camera.detachControl()
|
|
3099
|
-
this.particles?.dispose()
|
|
3100
|
-
this.particles = null
|
|
3101
3275
|
|
|
3102
3276
|
// Remove raycasting event listeners
|
|
3103
3277
|
if (this.onRaycast) {
|
|
@@ -5093,6 +5267,22 @@ export class Engine {
|
|
|
5093
5267
|
this.updateCameraUniforms()
|
|
5094
5268
|
this.updateShadowLightVP()
|
|
5095
5269
|
|
|
5270
|
+
// Depth of field's entire disabled cost is this branch: depth stays in
|
|
5271
|
+
// TBDR tile memory (discard) unless something in the composite reads it this
|
|
5272
|
+
// frame. Two things can — the DoF gather, and the depth handed to a
|
|
5273
|
+
// foreground effect — and either one makes the pass store it.
|
|
5274
|
+
//
|
|
5275
|
+
// The uniform refresh is shared for the same reason: linearDepth() inverts
|
|
5276
|
+
// the z-buffer with projA/projB out of dofU[2], which track the camera's
|
|
5277
|
+
// near/far and so must be rewritten every frame either reader is live. A
|
|
5278
|
+
// foreground with a stale pair would read metres from the wrong frustum. The
|
|
5279
|
+
// write leaves dofU[0].x at 0 while DoF is off, so refreshing it does not
|
|
5280
|
+
// switch the gather on.
|
|
5281
|
+
const dofOn = this.depthOfField.enabled
|
|
5282
|
+
const depthRead = dofOn || (this.effect?.hasForeground ?? false)
|
|
5283
|
+
this.renderPassDescriptor.depthStencilAttachment!.depthStoreOp = depthRead ? "store" : "discard"
|
|
5284
|
+
if (depthRead) this.writeDepthOfFieldUniforms()
|
|
5285
|
+
|
|
5096
5286
|
const encoder = this.device.createCommandEncoder()
|
|
5097
5287
|
|
|
5098
5288
|
// GPU vertex morphs: write morphed positions into vertex buffers before any pass reads
|
|
@@ -5139,9 +5329,6 @@ export class Engine {
|
|
|
5139
5329
|
this.forEachInstance((inst) => {
|
|
5140
5330
|
if (inst.model.visible) this.renderModelTransparentPhase(pass, inst)
|
|
5141
5331
|
})
|
|
5142
|
-
// Particle emissions last: additive light over everything, depth-tested
|
|
5143
|
-
// against the scene, feeding the bloom mask so the sparks glow.
|
|
5144
|
-
this.particles?.draw(pass, performance.now() / 1000)
|
|
5145
5332
|
pass.end()
|
|
5146
5333
|
|
|
5147
5334
|
// Bloom pyramid (EEVEE 3.6):
|
|
@@ -5757,7 +5944,7 @@ export class Engine {
|
|
|
5757
5944
|
// is LEFT-HANDED (+Z forward, see Mat4.lookAtInto), so the world-space
|
|
5758
5945
|
// right/up/FORWARD vectors are rows 0/1/2 of its rotation block directly
|
|
5759
5946
|
// (column-major storage: row i = values[i], values[i+4], values[i+8]).
|
|
5760
|
-
if ((this.backdropEquirectView || this.
|
|
5947
|
+
if ((this.backdropEquirectView || this.effect) && this.compositeUniformBuffer) {
|
|
5761
5948
|
const v = viewMatrix.values
|
|
5762
5949
|
const u = this.compositeUniformData
|
|
5763
5950
|
const tanHalf = Math.tan((this.camera.fov ?? Math.PI / 4) / 2)
|
|
@@ -5775,9 +5962,15 @@ export class Engine {
|
|
|
5775
5962
|
u[22] = v[10]
|
|
5776
5963
|
u[23] = 0
|
|
5777
5964
|
// Effect clock + canvas size (viewU[6]) — written on the same refresh.
|
|
5778
|
-
u[24] = (performance.now() - this.
|
|
5965
|
+
u[24] = (performance.now() - this.effectEpochMs) / 1000
|
|
5779
5966
|
u[26] = this.canvas.width
|
|
5780
5967
|
u[27] = this.canvas.height
|
|
5968
|
+
// Camera world position (viewU[10]) — the other half of bgWorldPos. It
|
|
5969
|
+
// rides this refresh rather than writeCompositeViewUniforms because it
|
|
5970
|
+
// changes every frame the camera does, exactly like the basis above.
|
|
5971
|
+
u[40] = cameraPos.x
|
|
5972
|
+
u[41] = cameraPos.y
|
|
5973
|
+
u[42] = cameraPos.z
|
|
5781
5974
|
this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
|
|
5782
5975
|
}
|
|
5783
5976
|
}
|
package/src/graph/registry.ts
CHANGED
|
@@ -146,6 +146,17 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
|
|
|
146
146
|
contextOutputs: { color: "material.diffuseColor" },
|
|
147
147
|
},
|
|
148
148
|
|
|
149
|
+
// The PMX material's sphere map, which is where an MMD model keeps its
|
|
150
|
+
// highlights — every PMX ships one, and hair without it reads flat. The mode
|
|
151
|
+
// is the material's own (.sph multiplies the shaded base, .spa adds a
|
|
152
|
+
// highlight), so a graph asks for the effect and the model decides which; a
|
|
153
|
+
// material with no sphere texture is an exact no-op.
|
|
154
|
+
sphere_map: {
|
|
155
|
+
inputs: { base: C([0, 0, 0], true), strength: F(1) },
|
|
156
|
+
outputs: { color: "color" },
|
|
157
|
+
emit: (a) => `pmx_sphere_map(${a.base}, ${a.strength}, n)`,
|
|
158
|
+
},
|
|
159
|
+
|
|
149
160
|
// ── Literals as nodes (for editor ergonomics; inlined literals work too) ──
|
|
150
161
|
value: { inputs: { value: F(0) }, outputs: { value: "float" }, emit: (a) => a.value },
|
|
151
162
|
rgb: { inputs: { color: C() }, outputs: { color: "color" }, emit: (a) => a.color },
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
export {
|
|
2
2
|
Engine,
|
|
3
3
|
DEFAULT_BLOOM_OPTIONS,
|
|
4
|
+
DEFAULT_DEPTH_OF_FIELD_OPTIONS,
|
|
4
5
|
DEFAULT_VIEW_TRANSFORM,
|
|
5
6
|
DEFAULT_COLOR_GRADING,
|
|
6
7
|
type ColorGradingOptions,
|
|
7
8
|
type EngineStats,
|
|
8
9
|
type EngineOptions,
|
|
9
10
|
type BloomOptions,
|
|
11
|
+
type DepthOfFieldOptions,
|
|
10
12
|
type ViewTransformOptions,
|
|
11
13
|
type LoadModelFromFilesOptions,
|
|
12
14
|
type MaterialPreset,
|
|
@@ -15,8 +17,8 @@ export {
|
|
|
15
17
|
type GizmoDragEvent,
|
|
16
18
|
type GizmoDragCallback,
|
|
17
19
|
type GizmoDragKind,
|
|
18
|
-
type
|
|
19
|
-
type
|
|
20
|
+
type EffectParamValue,
|
|
21
|
+
type EffectResult,
|
|
20
22
|
} from "./engine"
|
|
21
23
|
export { parsePmxFolderInput, pmxFileAtRelativePath, type PmxFolderInputResult } from "./folder-upload"
|
|
22
24
|
export {
|
|
@@ -99,4 +101,3 @@ export { PmxLoader } from "./pmx-loader"
|
|
|
99
101
|
export { CameraAnimation, type CameraPose } from "./camera-animation"
|
|
100
102
|
export { RezePhysics } from "./physics"
|
|
101
103
|
export type { WindOptions } from "./physics/world"
|
|
102
|
-
export { type ParticleEmitOptions } from "./particles"
|