reze-engine 0.40.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 +56 -30
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +124 -63
- package/dist/graph/registry.d.ts.map +1 -1
- package/dist/graph/registry.js +10 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- 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 +99 -46
- package/package.json +1 -1
- package/src/engine.ts +140 -71
- package/src/graph/registry.ts +11 -0
- package/src/index.ts +2 -2
- package/src/shaders/materials/nodes.ts +21 -0
- package/src/shaders/passes/composite.ts +126 -62
package/src/engine.ts
CHANGED
|
@@ -235,14 +235,17 @@ export type SunOptions = {
|
|
|
235
235
|
direction?: Vec3
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
-
/**
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
export type
|
|
242
|
-
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 = {
|
|
243
243
|
ok: boolean
|
|
244
244
|
/** Compile/validation errors, line:col relative to the USER's WGSL. */
|
|
245
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 }
|
|
246
249
|
}
|
|
247
250
|
|
|
248
251
|
export type CameraOptions = {
|
|
@@ -772,7 +775,10 @@ export class Engine {
|
|
|
772
775
|
private depthReadView: GPUTextureView | null = null
|
|
773
776
|
private compositeUniformBuffer!: GPUBuffer
|
|
774
777
|
// [exposure, invGamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
|
|
775
|
-
|
|
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)
|
|
776
782
|
/** Composite background (display-space sRGB 0–1) — null = transparent canvas. */
|
|
777
783
|
private backgroundColor: Vec3 | null = null
|
|
778
784
|
// 360 backdrop (equirectangular skybox, sampled by view ray in composite).
|
|
@@ -780,21 +786,27 @@ export class Engine {
|
|
|
780
786
|
private backdropEquirectView: GPUTextureView | null = null
|
|
781
787
|
private fallbackEquirectTexture!: GPUTexture
|
|
782
788
|
private fallbackEquirectView!: GPUTextureView
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
//
|
|
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
|
|
786
793
|
// recompile (the same instant tier as setStyleParam).
|
|
787
|
-
private
|
|
794
|
+
private effect: {
|
|
788
795
|
wgsl: string
|
|
789
796
|
paramLayout: Map<string, { offset: number; comps: 1 | 3 }>
|
|
790
797
|
paramsBuffer: GPUBuffer | null
|
|
791
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
|
|
792
804
|
} | null = null
|
|
793
805
|
/** Bound at composite binding 7 when no effect (or a param-less one) is set. */
|
|
794
806
|
private bgParamsDummyBuffer!: GPUBuffer
|
|
795
807
|
private compositePipelineLayout!: GPUPipelineLayout
|
|
796
|
-
/** time=0 origin for the active effect — reset each
|
|
797
|
-
private
|
|
808
|
+
/** time=0 origin for the active effect — reset each setEffect. */
|
|
809
|
+
private effectEpochMs = 0
|
|
798
810
|
private compositeBloomView: GPUTextureView | null = null
|
|
799
811
|
|
|
800
812
|
// EEVEE-style bloom pyramid (mirrors Blender 3.6 effect_bloom_frag.glsl):
|
|
@@ -1062,10 +1074,10 @@ export class Engine {
|
|
|
1062
1074
|
u[8] = bg?.x ?? 0
|
|
1063
1075
|
u[9] = bg?.y ?? 0
|
|
1064
1076
|
u[10] = bg?.z ?? 0
|
|
1065
|
-
// Base-layer mode
|
|
1066
|
-
//
|
|
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.
|
|
1067
1080
|
u[11] = this.backdropEquirectView ? 2 : bg ? 1 : 0
|
|
1068
|
-
u[25] = this.backgroundEffect ? 1 : 0
|
|
1069
1081
|
u[26] = this.canvas.width
|
|
1070
1082
|
u[27] = this.canvas.height
|
|
1071
1083
|
// ── Grade (viewU[7..9]) ── The UI's three tonal COLORS map to ASC CDL here,
|
|
@@ -1133,7 +1145,7 @@ export class Engine {
|
|
|
1133
1145
|
{ binding: 4, resource: this.maskResolveView },
|
|
1134
1146
|
{ binding: 5, resource: this.filmicLutView },
|
|
1135
1147
|
{ binding: 6, resource: this.backdropEquirectView ?? this.fallbackEquirectView },
|
|
1136
|
-
{ binding: 7, resource: { buffer: this.
|
|
1148
|
+
{ binding: 7, resource: { buffer: this.effect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
|
|
1137
1149
|
{ binding: 8, resource: this.depthReadView },
|
|
1138
1150
|
{ binding: 9, resource: { buffer: this.dofUniformBuffer } },
|
|
1139
1151
|
],
|
|
@@ -1200,46 +1212,73 @@ export class Engine {
|
|
|
1200
1212
|
}
|
|
1201
1213
|
|
|
1202
1214
|
/**
|
|
1203
|
-
* Install
|
|
1204
|
-
*
|
|
1205
|
-
*
|
|
1206
|
-
* or transparency) — its alpha lets the base show through, so a starfield is
|
|
1207
|
-
* stars over the user's background color. Display-space: never affects
|
|
1208
|
-
* lighting, bloom, or tonemapping, and is captured by offline export like any
|
|
1209
|
-
* background.
|
|
1210
|
-
*
|
|
1211
|
-
* `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:
|
|
1212
1218
|
*
|
|
1213
1219
|
* fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f
|
|
1220
|
+
* fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f
|
|
1221
|
+
*
|
|
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.
|
|
1231
|
+
*
|
|
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.
|
|
1214
1239
|
*
|
|
1215
|
-
*
|
|
1216
|
-
*
|
|
1217
|
-
*
|
|
1218
|
-
*
|
|
1219
|
-
* f32, Vec3 → vec3f) and are later tweaked without recompiling via
|
|
1220
|
-
* setBackgroundEffectParam.
|
|
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.
|
|
1221
1244
|
*
|
|
1222
|
-
* Compiles off the hot path (async pipelines): on failure the previous
|
|
1223
|
-
*
|
|
1224
|
-
*
|
|
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.
|
|
1225
1248
|
*/
|
|
1226
|
-
async
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
): Promise<BackgroundEffectResult> {
|
|
1230
|
-
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 }
|
|
1231
1252
|
|
|
1232
1253
|
if (wgsl === null) {
|
|
1233
|
-
this.
|
|
1234
|
-
this.
|
|
1254
|
+
this.effect?.paramsBuffer?.destroy()
|
|
1255
|
+
this.effect = null
|
|
1235
1256
|
const module = this.device.createShaderModule({ label: "composite shader", code: buildCompositeShader(null) })
|
|
1236
1257
|
this.compositePipelineIdentity = this.makeCompositePipeline(module, false, "composite pipeline (gamma=1)")
|
|
1237
1258
|
this.compositePipelineGamma = this.makeCompositePipeline(module, true, "composite pipeline (gamma!=1)")
|
|
1238
1259
|
this.rebuildCompositeBindGroup()
|
|
1239
1260
|
this.writeCompositeViewUniforms()
|
|
1240
|
-
return { ok: true, diagnostics: [] }
|
|
1261
|
+
return { ok: true, diagnostics: [], mounts: noMounts }
|
|
1241
1262
|
}
|
|
1242
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
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
const mounts = { background: hasBackground, foreground: hasForeground }
|
|
1281
|
+
|
|
1243
1282
|
// ── Params: codegen a WGSL struct and mirror its uniform layout on the CPU.
|
|
1244
1283
|
// Fields are emitted in declaration order; offsets follow WGSL's natural
|
|
1245
1284
|
// uniform rules (f32 align 4, vec3f align 16 size 12), computed identically
|
|
@@ -1250,7 +1289,7 @@ export class Engine {
|
|
|
1250
1289
|
let cursor = 0
|
|
1251
1290
|
for (const [name, value] of entries) {
|
|
1252
1291
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
1253
|
-
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 }
|
|
1254
1293
|
}
|
|
1255
1294
|
const isVec = typeof value !== "number"
|
|
1256
1295
|
const align = isVec ? 16 : 4
|
|
@@ -1270,22 +1309,22 @@ export class Engine {
|
|
|
1270
1309
|
}
|
|
1271
1310
|
}
|
|
1272
1311
|
const paramsDecl = entries.length
|
|
1273
|
-
? `struct
|
|
1312
|
+
? `struct EffectParams {\n${fields.join("\n")}\n}\n@group(0) @binding(7) var<uniform> params: EffectParams;\n`
|
|
1274
1313
|
: ""
|
|
1275
1314
|
|
|
1276
1315
|
// ── Compile with validation captured, not thrown at the console. Line
|
|
1277
1316
|
// numbers in diagnostics are rebased to the USER's source.
|
|
1278
|
-
const source = buildCompositeShader({ wgsl, paramsDecl })
|
|
1317
|
+
const source = buildCompositeShader({ wgsl, paramsDecl, hasBackground, hasForeground })
|
|
1279
1318
|
const userLineOffset = source.slice(0, source.indexOf(wgsl)).split("\n").length - 1
|
|
1280
1319
|
this.device.pushErrorScope("validation")
|
|
1281
|
-
const module = this.device.createShaderModule({ label: "composite shader (
|
|
1320
|
+
const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source })
|
|
1282
1321
|
const info = await module.getCompilationInfo()
|
|
1283
1322
|
const scopeErr = await this.device.popErrorScope()
|
|
1284
1323
|
const diagnostics = info.messages
|
|
1285
1324
|
.filter((m) => m.type === "error")
|
|
1286
1325
|
.map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`)
|
|
1287
1326
|
if (diagnostics.length === 0 && scopeErr) diagnostics.push(scopeErr.message)
|
|
1288
|
-
if (diagnostics.length > 0) return { ok: false, diagnostics }
|
|
1327
|
+
if (diagnostics.length > 0) return { ok: false, diagnostics, mounts }
|
|
1289
1328
|
let identity: GPURenderPipeline
|
|
1290
1329
|
let gamma: GPURenderPipeline
|
|
1291
1330
|
try {
|
|
@@ -1303,37 +1342,42 @@ export class Engine {
|
|
|
1303
1342
|
primitive: { topology: "triangle-list" },
|
|
1304
1343
|
})
|
|
1305
1344
|
;[identity, gamma] = await Promise.all([
|
|
1306
|
-
make(false, "composite pipeline (
|
|
1307
|
-
make(true, "composite pipeline (
|
|
1345
|
+
make(false, "composite pipeline (effect, gamma=1)"),
|
|
1346
|
+
make(true, "composite pipeline (effect, gamma!=1)"),
|
|
1308
1347
|
])
|
|
1309
1348
|
} catch (e) {
|
|
1310
|
-
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)] }
|
|
1349
|
+
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts }
|
|
1311
1350
|
}
|
|
1312
1351
|
|
|
1313
1352
|
// ── Swap — only now does the old effect (and its params buffer) go away.
|
|
1314
|
-
this.
|
|
1353
|
+
this.effect?.paramsBuffer?.destroy()
|
|
1315
1354
|
let paramsBuffer: GPUBuffer | null = null
|
|
1316
1355
|
if (entries.length) {
|
|
1317
1356
|
paramsBuffer = this.device.createBuffer({
|
|
1318
|
-
label: "
|
|
1357
|
+
label: "effect params",
|
|
1319
1358
|
size: paramsData.byteLength,
|
|
1320
1359
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1321
1360
|
})
|
|
1322
1361
|
this.device.queue.writeBuffer(paramsBuffer, 0, paramsData)
|
|
1323
1362
|
}
|
|
1324
|
-
this.
|
|
1363
|
+
this.effect = { wgsl, paramLayout: layout, paramsBuffer, paramsData, hasBackground, hasForeground }
|
|
1325
1364
|
this.compositePipelineIdentity = identity
|
|
1326
1365
|
this.compositePipelineGamma = gamma
|
|
1327
|
-
this.
|
|
1366
|
+
this.effectEpochMs = performance.now()
|
|
1328
1367
|
this.rebuildCompositeBindGroup()
|
|
1329
1368
|
this.writeCompositeViewUniforms()
|
|
1330
|
-
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 }
|
|
1331
1375
|
}
|
|
1332
1376
|
|
|
1333
|
-
/** Write one
|
|
1334
|
-
*
|
|
1335
|
-
|
|
1336
|
-
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
|
|
1337
1381
|
if (!fx || !fx.paramsBuffer) return
|
|
1338
1382
|
const slot = fx.paramLayout.get(name)
|
|
1339
1383
|
if (!slot) return
|
|
@@ -1407,7 +1451,10 @@ export class Engine {
|
|
|
1407
1451
|
if (!this.device || !this.dofUniformBuffer) return
|
|
1408
1452
|
const d = this.depthOfField
|
|
1409
1453
|
const u = this.dofUniformData
|
|
1410
|
-
|
|
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
|
|
1411
1458
|
u[0] = d.enabled ? 1 : 0
|
|
1412
1459
|
u[1] = auto?.distance ?? Math.max(d.focusDistance, 0.05)
|
|
1413
1460
|
// In auto mode the authored range is a floor — the sharp band never cuts
|
|
@@ -2286,11 +2333,12 @@ export class Engine {
|
|
|
2286
2333
|
// mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
|
|
2287
2334
|
this.compositeUniformBuffer = this.device.createBuffer({
|
|
2288
2335
|
label: "composite view uniforms",
|
|
2289
|
-
//
|
|
2336
|
+
// 11 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
|
|
2290
2337
|
// (bg rgb, mode) · camera right/up/forward basis for the 360 skybox ray ·
|
|
2291
|
-
// (time, _, canvas width, canvas height) for user
|
|
2292
|
-
//
|
|
2293
|
-
|
|
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,
|
|
2294
2342
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
2295
2343
|
})
|
|
2296
2344
|
this.dofUniformBuffer = this.device.createBuffer({
|
|
@@ -3010,6 +3058,13 @@ export class Engine {
|
|
|
3010
3058
|
return this.cameraAnimation !== null
|
|
3011
3059
|
}
|
|
3012
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
|
+
|
|
3013
3068
|
/** Drop the loaded camera VMD and return to orbit control. */
|
|
3014
3069
|
clearCameraVmd(): void {
|
|
3015
3070
|
this.cameraAnimation = null
|
|
@@ -5213,12 +5268,20 @@ export class Engine {
|
|
|
5213
5268
|
this.updateShadowLightVP()
|
|
5214
5269
|
|
|
5215
5270
|
// Depth of field's entire disabled cost is this branch: depth stays in
|
|
5216
|
-
// TBDR tile memory (discard) unless the composite
|
|
5217
|
-
// frame.
|
|
5218
|
-
//
|
|
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.
|
|
5219
5281
|
const dofOn = this.depthOfField.enabled
|
|
5220
|
-
|
|
5221
|
-
|
|
5282
|
+
const depthRead = dofOn || (this.effect?.hasForeground ?? false)
|
|
5283
|
+
this.renderPassDescriptor.depthStencilAttachment!.depthStoreOp = depthRead ? "store" : "discard"
|
|
5284
|
+
if (depthRead) this.writeDepthOfFieldUniforms()
|
|
5222
5285
|
|
|
5223
5286
|
const encoder = this.device.createCommandEncoder()
|
|
5224
5287
|
|
|
@@ -5881,7 +5944,7 @@ export class Engine {
|
|
|
5881
5944
|
// is LEFT-HANDED (+Z forward, see Mat4.lookAtInto), so the world-space
|
|
5882
5945
|
// right/up/FORWARD vectors are rows 0/1/2 of its rotation block directly
|
|
5883
5946
|
// (column-major storage: row i = values[i], values[i+4], values[i+8]).
|
|
5884
|
-
if ((this.backdropEquirectView || this.
|
|
5947
|
+
if ((this.backdropEquirectView || this.effect) && this.compositeUniformBuffer) {
|
|
5885
5948
|
const v = viewMatrix.values
|
|
5886
5949
|
const u = this.compositeUniformData
|
|
5887
5950
|
const tanHalf = Math.tan((this.camera.fov ?? Math.PI / 4) / 2)
|
|
@@ -5899,9 +5962,15 @@ export class Engine {
|
|
|
5899
5962
|
u[22] = v[10]
|
|
5900
5963
|
u[23] = 0
|
|
5901
5964
|
// Effect clock + canvas size (viewU[6]) — written on the same refresh.
|
|
5902
|
-
u[24] = (performance.now() - this.
|
|
5965
|
+
u[24] = (performance.now() - this.effectEpochMs) / 1000
|
|
5903
5966
|
u[26] = this.canvas.width
|
|
5904
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
|
|
5905
5974
|
this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
|
|
5906
5975
|
}
|
|
5907
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
|
@@ -17,8 +17,8 @@ export {
|
|
|
17
17
|
type GizmoDragEvent,
|
|
18
18
|
type GizmoDragCallback,
|
|
19
19
|
type GizmoDragKind,
|
|
20
|
-
type
|
|
21
|
-
type
|
|
20
|
+
type EffectParamValue,
|
|
21
|
+
type EffectResult,
|
|
22
22
|
} from "./engine"
|
|
23
23
|
export { parsePmxFolderInput, pmxFileAtRelativePath, type PmxFolderInputResult } from "./folder-upload"
|
|
24
24
|
export {
|
|
@@ -506,6 +506,27 @@ fn principled_sheen(NV: f32) -> f32 {
|
|
|
506
506
|
// sheen — 0 disables. Scales the sheen diffuse add; cloth/stockings use ~0.7.
|
|
507
507
|
// sheen_tint — 0 = white sheen, 1 = fully tinted by base. Multiplied by sheen,
|
|
508
508
|
// so value is don't-care when sheen=0.
|
|
509
|
+
// The PMX sphere map, applied the way MMD applies it: the texture is a
|
|
510
|
+
// VIEW-SPACE lighting mask, sampled by the camera-space normal rather than by
|
|
511
|
+
// any UV the mesh carries, which is why it tracks the viewer and reads as a
|
|
512
|
+
// highlight. The material's own mode picks the operator — .sph (1) multiplies
|
|
513
|
+
// the shaded base, .spa (2) adds — so a graph asks for the effect and the model
|
|
514
|
+
// decides which it meant. Mode 0, or a material with no sphere texture (the 1×1
|
|
515
|
+
// white fallback is bound then), is an exact no-op.
|
|
516
|
+
fn pmx_sphere_map(base: vec3f, strength: f32, N: vec3f) -> vec3f {
|
|
517
|
+
if (material.sphereMode < 0.5) {
|
|
518
|
+
return base;
|
|
519
|
+
}
|
|
520
|
+
let camera_n = safe_normal((camera.view * vec4f(N, 0.0)).xyz);
|
|
521
|
+
let sphere_uv = clamp(camera_n.xy * 0.5 + vec2f(0.5), vec2f(0.0), vec2f(1.0));
|
|
522
|
+
let sphere_rgb = textureSample(sphereTexture, diffuseSampler, sphere_uv).rgb;
|
|
523
|
+
let amount = max(strength, 0.0);
|
|
524
|
+
if (material.sphereMode < 1.5) {
|
|
525
|
+
return mix(base, base * sphere_rgb, min(amount, 1.0));
|
|
526
|
+
}
|
|
527
|
+
return base + sphere_rgb * amount;
|
|
528
|
+
}
|
|
529
|
+
|
|
509
530
|
struct PrincipledIn {
|
|
510
531
|
base: vec3f,
|
|
511
532
|
metallic: f32,
|