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/src/engine.ts CHANGED
@@ -235,14 +235,17 @@ export type SunOptions = {
235
235
  direction?: Vec3
236
236
  }
237
237
 
238
- /** A background-effect param: number → f32, vector-like → vec3f (see
239
- * setBackgroundEffect). Structural {x,y,z} rather than the Vec3 class so
240
- * JSON-derived values (a shared scene document's params) pass straight in. */
241
- export type BackgroundEffectParamValue = number | { x: number; y: number; z: number }
242
- export type BackgroundEffectResult = {
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
- private readonly compositeUniformData = new Float32Array(40)
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
- // User WGSL background effect (background mode 3, setBackgroundEffect). The
784
- // composite pipelines are REBUILT with the user code injected; params live in
785
- // their own uniform buffer so setBackgroundEffectParam is a write, not a
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 backgroundEffect: {
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 setBackgroundEffect. */
797
- private bgEffectEpochMs = 0
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; a user effect is a separate LAYER flagged at u[25] and
1066
- // over-composited onto whichever base is active.
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.backgroundEffect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
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 a WGSL background effect (shadertoy-style) as a LAYER between the
1204
- * base background and the scene: rendered per-pixel in the composite pass and
1205
- * over-composited onto whichever base is active (solid color, 360 equirect,
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
- * where `ray` is the pixel's normalized world-space view direction (LH, +Z
1216
- * forward what the skybox samples by), `uv` is 0..1 bottom-left origin,
1217
- * `time` is seconds since apply, and `bgResolution()` gives the canvas size.
1218
- * Return sRGB + alpha. Declared `params` arrive as `params.<name>` (number
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
- * background is KEPT and diagnostics are returned with line numbers relative
1224
- * to the user's WGSL. Pass null to remove the effect.
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 setBackgroundEffect(
1227
- wgsl: string | null,
1228
- params?: Record<string, BackgroundEffectParamValue>,
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.backgroundEffect?.paramsBuffer?.destroy()
1234
- this.backgroundEffect = null
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 BgParams {\n${fields.join("\n")}\n}\n@group(0) @binding(7) var<uniform> params: BgParams;\n`
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 (bg effect)", code: source })
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 (bg effect, gamma=1)"),
1307
- make(true, "composite pipeline (bg effect, gamma!=1)"),
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.backgroundEffect?.paramsBuffer?.destroy()
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: "bg effect params",
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.backgroundEffect = { wgsl, paramLayout: layout, paramsBuffer, paramsData }
1363
+ this.effect = { wgsl, paramLayout: layout, paramsBuffer, paramsData, hasBackground, hasForeground }
1325
1364
  this.compositePipelineIdentity = identity
1326
1365
  this.compositePipelineGamma = gamma
1327
- this.bgEffectEpochMs = performance.now()
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 background-effect param (declared at setBackgroundEffect) — a
1334
- * uniform write, no recompile; the instant tier, like setStyleParam. */
1335
- setBackgroundEffectParam(name: string, value: BackgroundEffectParamValue): void {
1336
- const fx = this.backgroundEffect
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
- const auto = d.focusMode === "auto" ? this.getModelBodyFocus() : null
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
- // 10 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
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 background effects ·
2292
- // three grade vectors (CDL offset+contrast, power+saturation, slope+flag).
2293
- size: 160,
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 gather reads it this
5217
- // frame. Enabled frames also refresh the uniforms auto-focus tracks the
5218
- // character and the depth-inversion constants track near/far.
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
- this.renderPassDescriptor.depthStencilAttachment!.depthStoreOp = dofOn ? "store" : "discard"
5221
- if (dofOn) this.writeDepthOfFieldUniforms()
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.backgroundEffect) && this.compositeUniformBuffer) {
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.bgEffectEpochMs) / 1000
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
  }
@@ -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 BackgroundEffectParamValue,
21
- type BackgroundEffectResult,
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,