reze-engine 0.24.1 → 0.25.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
@@ -29,7 +29,7 @@ import {
29
29
  BLOOM_DOWNSAMPLE_SHADER_WGSL,
30
30
  BLOOM_UPSAMPLE_SHADER_WGSL,
31
31
  } from "./shaders/passes/bloom"
32
- import { COMPOSITE_SHADER_WGSL } from "./shaders/passes/composite"
32
+ import { buildCompositeShader } from "./shaders/passes/composite"
33
33
  import { PICK_SHADER_WGSL } from "./shaders/passes/pick"
34
34
  import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap"
35
35
  import { compileGraph, type CompileOptions, type StyleSlot } from "./graph/compile"
@@ -223,6 +223,16 @@ export type SunOptions = {
223
223
  direction?: Vec3
224
224
  }
225
225
 
226
+ /** A background-effect param: number → f32, vector-like → vec3f (see
227
+ * setBackgroundEffect). Structural {x,y,z} rather than the Vec3 class so
228
+ * JSON-derived values (a shared scene document's params) pass straight in. */
229
+ export type BackgroundEffectParamValue = number | { x: number; y: number; z: number }
230
+ export type BackgroundEffectResult = {
231
+ ok: boolean
232
+ /** Compile/validation errors, line:col relative to the USER's WGSL. */
233
+ diagnostics: string[]
234
+ }
235
+
226
236
  export type CameraOptions = {
227
237
  /** Orbit distance from target. */
228
238
  distance?: number
@@ -392,6 +402,83 @@ interface GpuMorph {
392
402
  dispatchNeeded: boolean
393
403
  }
394
404
 
405
+ // ── Sheer-material detection ──────────────────────────────────────────────────
406
+ // PMX carries no "translucent" flag: a see-through veil usually has diffuse
407
+ // alpha 1.0 and does its transparency entirely in the TEXTURE's alpha channel.
408
+ // Classifying by material alpha alone put such cloth in the opaque bucket,
409
+ // where it draws in PMX order with depth writes — anything the engine draws
410
+ // after it (the hair render-class draws LAST for the eye-stencil effect) got
411
+ // depth-rejected behind the veil, so you saw the body through it but not the
412
+ // hair. These helpers measure a material's real coverage by sampling the
413
+ // texture's alpha at the material's own triangle CENTROIDS — centroids, not
414
+ // vertices, because hair-card corners sit in transparent texture margins and
415
+ // vertex sampling would misclassify hair (which must stay opaque-bucket for
416
+ // stencil interplay and shadows).
417
+
418
+ /** Downsampled alpha plane of a decoded texture (≤128², nearest-sampled). */
419
+ function buildAlphaSampler(
420
+ source: ImageBitmap | null,
421
+ rgba: Uint8Array | null,
422
+ width: number,
423
+ height: number,
424
+ ): { a: Uint8ClampedArray; w: number; h: number } | null {
425
+ try {
426
+ const w = Math.max(1, Math.min(128, width))
427
+ const h = Math.max(1, Math.min(128, height))
428
+ const canvas = new OffscreenCanvas(w, h)
429
+ const cx = canvas.getContext("2d", { willReadFrequently: true })
430
+ if (!cx) return null
431
+ if (source) {
432
+ cx.drawImage(source, 0, 0, w, h)
433
+ } else if (rgba) {
434
+ const tmp = new OffscreenCanvas(width, height)
435
+ const tcx = tmp.getContext("2d")
436
+ if (!tcx) return null
437
+ tcx.putImageData(new ImageData(new Uint8ClampedArray(rgba), width, height), 0, 0)
438
+ cx.drawImage(tmp, 0, 0, w, h)
439
+ } else {
440
+ return null
441
+ }
442
+ const img = cx.getImageData(0, 0, w, h).data
443
+ const a = new Uint8ClampedArray(w * h)
444
+ for (let i = 0; i < w * h; i++) a[i] = img[i * 4 + 3]
445
+ return { a, w, h }
446
+ } catch {
447
+ return null
448
+ }
449
+ }
450
+
451
+ /** Average texture alpha over ≤400 of the material's triangle centroids —
452
+ * below the threshold the material renders as a transparent-bucket draw. */
453
+ const SHEER_ALPHA_THRESHOLD = 0.7
454
+ function materialIsSheer(
455
+ verts: Float32Array,
456
+ indices: Uint32Array,
457
+ firstIndex: number,
458
+ count: number,
459
+ sampler: { a: Uint8ClampedArray; w: number; h: number } | null | undefined,
460
+ ): boolean {
461
+ if (!sampler) return false
462
+ const triCount = Math.floor(count / 3)
463
+ if (triCount === 0) return false
464
+ const step = Math.max(1, Math.floor(triCount / 400))
465
+ let sum = 0
466
+ let n = 0
467
+ for (let t = 0; t < triCount; t += step) {
468
+ const i0 = indices[firstIndex + t * 3]
469
+ const i1 = indices[firstIndex + t * 3 + 1]
470
+ const i2 = indices[firstIndex + t * 3 + 2]
471
+ const u = (verts[i0 * 8 + 6] + verts[i1 * 8 + 6] + verts[i2 * 8 + 6]) / 3
472
+ const v = (verts[i0 * 8 + 7] + verts[i1 * 8 + 7] + verts[i2 * 8 + 7]) / 3
473
+ // Wrap (MMD UVs may tile), then nearest-sample the downsampled plane.
474
+ const x = Math.min(sampler.w - 1, Math.max(0, Math.floor((u - Math.floor(u)) * sampler.w)))
475
+ const y = Math.min(sampler.h - 1, Math.max(0, Math.floor((v - Math.floor(v)) * sampler.h)))
476
+ sum += sampler.a[y * sampler.w + x]
477
+ n++
478
+ }
479
+ return n > 0 && sum / n < SHEER_ALPHA_THRESHOLD * 255
480
+ }
481
+
395
482
  export class Engine {
396
483
  private static instance: Engine | null = null
397
484
 
@@ -545,7 +632,7 @@ export class Engine {
545
632
  private compositeBindGroup!: GPUBindGroup
546
633
  private compositeUniformBuffer!: GPUBuffer
547
634
  // [exposure, invGamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
548
- private readonly compositeUniformData = new Float32Array(24)
635
+ private readonly compositeUniformData = new Float32Array(28)
549
636
  /** Composite background (display-space sRGB 0–1) — null = transparent canvas. */
550
637
  private backgroundColor: Vec3 | null = null
551
638
  // 360 backdrop (equirectangular skybox, sampled by view ray in composite).
@@ -553,6 +640,21 @@ export class Engine {
553
640
  private backdropEquirectView: GPUTextureView | null = null
554
641
  private fallbackEquirectTexture!: GPUTexture
555
642
  private fallbackEquirectView!: GPUTextureView
643
+ // User WGSL background effect (background mode 3, setBackgroundEffect). The
644
+ // composite pipelines are REBUILT with the user code injected; params live in
645
+ // their own uniform buffer so setBackgroundEffectParam is a write, not a
646
+ // recompile (the same instant tier as setStyleParam).
647
+ private backgroundEffect: {
648
+ wgsl: string
649
+ paramLayout: Map<string, { offset: number; comps: 1 | 3 }>
650
+ paramsBuffer: GPUBuffer | null
651
+ paramsData: Float32Array<ArrayBuffer>
652
+ } | null = null
653
+ /** Bound at composite binding 7 when no effect (or a param-less one) is set. */
654
+ private bgParamsDummyBuffer!: GPUBuffer
655
+ private compositePipelineLayout!: GPUPipelineLayout
656
+ /** time=0 origin for the active effect — reset each setBackgroundEffect. */
657
+ private bgEffectEpochMs = 0
556
658
  private compositeBloomView: GPUTextureView | null = null
557
659
 
558
660
  // EEVEE-style bloom pyramid (mirrors Blender 3.6 effect_bloom_frag.glsl):
@@ -627,6 +729,11 @@ export class Engine {
627
729
  private materialSampler!: GPUSampler
628
730
  private fallbackMaterialTexture!: GPUTexture
629
731
  private textureCache = new Map<string, GPUTexture>()
732
+ // Downsampled CPU alpha channel per texture (≤128², ~16KB) — kept so materials
733
+ // can be classified as SHEER at load by sampling alpha at their own UVs (the
734
+ // GPU texture can't be read back cheaply, and PMX diffuse alpha is usually 1.0
735
+ // even for see-through cloth: the translucency lives in the texture).
736
+ private textureAlphaCache = new Map<string, { a: Uint8ClampedArray; w: number; h: number } | null>()
630
737
  private mipBlitPipeline: GPURenderPipeline | null = null
631
738
  private mipBlitSampler: GPUSampler | null = null
632
739
  private _nextDefaultModelId = 0
@@ -769,7 +876,12 @@ export class Engine {
769
876
  u[8] = bg?.x ?? 0
770
877
  u[9] = bg?.y ?? 0
771
878
  u[10] = bg?.z ?? 0
879
+ // Base-layer mode; a user effect is a separate LAYER flagged at u[25] and
880
+ // over-composited onto whichever base is active.
772
881
  u[11] = this.backdropEquirectView ? 2 : bg ? 1 : 0
882
+ u[25] = this.backgroundEffect ? 1 : 0
883
+ u[26] = this.canvas.width
884
+ u[27] = this.canvas.height
773
885
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
774
886
  }
775
887
 
@@ -798,6 +910,7 @@ export class Engine {
798
910
  { binding: 4, resource: this.maskResolveView },
799
911
  { binding: 5, resource: this.filmicLutView },
800
912
  { binding: 6, resource: this.backdropEquirectView ?? this.fallbackEquirectView },
913
+ { binding: 7, resource: { buffer: this.backgroundEffect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
801
914
  ],
802
915
  })
803
916
  }
@@ -846,6 +959,168 @@ export class Engine {
846
959
  if (this.device && this.compositeUniformBuffer) this.writeCompositeViewUniforms()
847
960
  }
848
961
 
962
+ private makeCompositePipeline(module: GPUShaderModule, applyGamma: boolean, label: string): GPURenderPipeline {
963
+ return this.device.createRenderPipeline({
964
+ label,
965
+ layout: this.compositePipelineLayout,
966
+ vertex: { module, entryPoint: "vs" },
967
+ fragment: {
968
+ module,
969
+ entryPoint: "fs",
970
+ constants: { APPLY_GAMMA: applyGamma ? 1 : 0 },
971
+ targets: [{ format: this.presentationFormat }],
972
+ },
973
+ primitive: { topology: "triangle-list" },
974
+ })
975
+ }
976
+
977
+ /**
978
+ * Install a WGSL background effect (shadertoy-style) as a LAYER between the
979
+ * base background and the scene: rendered per-pixel in the composite pass and
980
+ * over-composited onto whichever base is active (solid color, 360 equirect,
981
+ * or transparency) — its alpha lets the base show through, so a starfield is
982
+ * stars over the user's background color. Display-space: never affects
983
+ * lighting, bloom, or tonemapping, and is captured by offline export like any
984
+ * background.
985
+ *
986
+ * `wgsl` must define:
987
+ *
988
+ * fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f
989
+ *
990
+ * where `ray` is the pixel's normalized world-space view direction (LH, +Z
991
+ * forward — what the skybox samples by), `uv` is 0..1 bottom-left origin,
992
+ * `time` is seconds since apply, and `bgResolution()` gives the canvas size.
993
+ * Return sRGB + alpha. Declared `params` arrive as `params.<name>` (number →
994
+ * f32, Vec3 → vec3f) and are later tweaked without recompiling via
995
+ * setBackgroundEffectParam.
996
+ *
997
+ * Compiles off the hot path (async pipelines): on failure the previous
998
+ * background is KEPT and diagnostics are returned with line numbers relative
999
+ * to the user's WGSL. Pass null to remove the effect.
1000
+ */
1001
+ async setBackgroundEffect(
1002
+ wgsl: string | null,
1003
+ params?: Record<string, BackgroundEffectParamValue>,
1004
+ ): Promise<BackgroundEffectResult> {
1005
+ if (!this.device) return { ok: false, diagnostics: ["setBackgroundEffect requires init() to have run"] }
1006
+
1007
+ if (wgsl === null) {
1008
+ this.backgroundEffect?.paramsBuffer?.destroy()
1009
+ this.backgroundEffect = null
1010
+ const module = this.device.createShaderModule({ label: "composite shader", code: buildCompositeShader(null) })
1011
+ this.compositePipelineIdentity = this.makeCompositePipeline(module, false, "composite pipeline (gamma=1)")
1012
+ this.compositePipelineGamma = this.makeCompositePipeline(module, true, "composite pipeline (gamma!=1)")
1013
+ this.rebuildCompositeBindGroup()
1014
+ this.writeCompositeViewUniforms()
1015
+ return { ok: true, diagnostics: [] }
1016
+ }
1017
+
1018
+ // ── Params: codegen a WGSL struct and mirror its uniform layout on the CPU.
1019
+ // Fields are emitted in declaration order; offsets follow WGSL's natural
1020
+ // uniform rules (f32 align 4, vec3f align 16 size 12), computed identically
1021
+ // on both sides so no reordering is needed.
1022
+ const entries = Object.entries(params ?? {})
1023
+ const layout = new Map<string, { offset: number; comps: 1 | 3 }>()
1024
+ const fields: string[] = []
1025
+ let cursor = 0
1026
+ for (const [name, value] of entries) {
1027
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
1028
+ return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`] }
1029
+ }
1030
+ const isVec = typeof value !== "number"
1031
+ const align = isVec ? 16 : 4
1032
+ const offset = Math.ceil(cursor / align) * align
1033
+ layout.set(name, { offset: offset / 4, comps: isVec ? 3 : 1 })
1034
+ fields.push(` ${name}: ${isVec ? "vec3f" : "f32"},`)
1035
+ cursor = offset + (isVec ? 12 : 4)
1036
+ }
1037
+ const paramsData = new Float32Array(Math.max(4, Math.ceil(cursor / 16) * 4))
1038
+ for (const [name, value] of entries) {
1039
+ const slot = layout.get(name)!
1040
+ if (typeof value === "number") paramsData[slot.offset] = value
1041
+ else {
1042
+ paramsData[slot.offset] = value.x
1043
+ paramsData[slot.offset + 1] = value.y
1044
+ paramsData[slot.offset + 2] = value.z
1045
+ }
1046
+ }
1047
+ const paramsDecl = entries.length
1048
+ ? `struct BgParams {\n${fields.join("\n")}\n}\n@group(0) @binding(7) var<uniform> params: BgParams;\n`
1049
+ : ""
1050
+
1051
+ // ── Compile with validation captured, not thrown at the console. Line
1052
+ // numbers in diagnostics are rebased to the USER's source.
1053
+ const source = buildCompositeShader({ wgsl, paramsDecl })
1054
+ const userLineOffset = source.slice(0, source.indexOf(wgsl)).split("\n").length - 1
1055
+ this.device.pushErrorScope("validation")
1056
+ const module = this.device.createShaderModule({ label: "composite shader (bg effect)", code: source })
1057
+ const info = await module.getCompilationInfo()
1058
+ const scopeErr = await this.device.popErrorScope()
1059
+ const diagnostics = info.messages
1060
+ .filter((m) => m.type === "error")
1061
+ .map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`)
1062
+ if (diagnostics.length === 0 && scopeErr) diagnostics.push(scopeErr.message)
1063
+ if (diagnostics.length > 0) return { ok: false, diagnostics }
1064
+ let identity: GPURenderPipeline
1065
+ let gamma: GPURenderPipeline
1066
+ try {
1067
+ const make = (applyGamma: boolean, label: string) =>
1068
+ this.device.createRenderPipelineAsync({
1069
+ label,
1070
+ layout: this.compositePipelineLayout,
1071
+ vertex: { module, entryPoint: "vs" },
1072
+ fragment: {
1073
+ module,
1074
+ entryPoint: "fs",
1075
+ constants: { APPLY_GAMMA: applyGamma ? 1 : 0 },
1076
+ targets: [{ format: this.presentationFormat }],
1077
+ },
1078
+ primitive: { topology: "triangle-list" },
1079
+ })
1080
+ ;[identity, gamma] = await Promise.all([
1081
+ make(false, "composite pipeline (bg effect, gamma=1)"),
1082
+ make(true, "composite pipeline (bg effect, gamma!=1)"),
1083
+ ])
1084
+ } catch (e) {
1085
+ return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)] }
1086
+ }
1087
+
1088
+ // ── Swap — only now does the old effect (and its params buffer) go away.
1089
+ this.backgroundEffect?.paramsBuffer?.destroy()
1090
+ let paramsBuffer: GPUBuffer | null = null
1091
+ if (entries.length) {
1092
+ paramsBuffer = this.device.createBuffer({
1093
+ label: "bg effect params",
1094
+ size: paramsData.byteLength,
1095
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1096
+ })
1097
+ this.device.queue.writeBuffer(paramsBuffer, 0, paramsData)
1098
+ }
1099
+ this.backgroundEffect = { wgsl, paramLayout: layout, paramsBuffer, paramsData }
1100
+ this.compositePipelineIdentity = identity
1101
+ this.compositePipelineGamma = gamma
1102
+ this.bgEffectEpochMs = performance.now()
1103
+ this.rebuildCompositeBindGroup()
1104
+ this.writeCompositeViewUniforms()
1105
+ return { ok: true, diagnostics: [] }
1106
+ }
1107
+
1108
+ /** Write one background-effect param (declared at setBackgroundEffect) — a
1109
+ * uniform write, no recompile; the instant tier, like setStyleParam. */
1110
+ setBackgroundEffectParam(name: string, value: BackgroundEffectParamValue): void {
1111
+ const fx = this.backgroundEffect
1112
+ if (!fx || !fx.paramsBuffer) return
1113
+ const slot = fx.paramLayout.get(name)
1114
+ if (!slot) return
1115
+ if (typeof value === "number") fx.paramsData[slot.offset] = value
1116
+ else {
1117
+ fx.paramsData[slot.offset] = value.x
1118
+ fx.paramsData[slot.offset + 1] = value.y
1119
+ fx.paramsData[slot.offset + 2] = value.z
1120
+ }
1121
+ this.device.queue.writeBuffer(fx.paramsBuffer, 0, fx.paramsData)
1122
+ }
1123
+
849
1124
  /** Patch bloom; GPU uniforms update immediately if `init()` has run. */
850
1125
  setBloomOptions(patch: Partial<BloomOptions>): void {
851
1126
  const b = this.bloomSettings
@@ -1643,9 +1918,15 @@ export class Engine {
1643
1918
  // mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
1644
1919
  this.compositeUniformBuffer = this.device.createBuffer({
1645
1920
  label: "composite view uniforms",
1646
- // 6 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
1647
- // (bg rgb, mode) · camera right/up/forward basis for the 360 skybox ray.
1648
- size: 96,
1921
+ // 7 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
1922
+ // (bg rgb, mode) · camera right/up/forward basis for the 360 skybox ray ·
1923
+ // (time, _, canvas width, canvas height) for user background effects.
1924
+ size: 112,
1925
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1926
+ })
1927
+ this.bgParamsDummyBuffer = this.device.createBuffer({
1928
+ label: "bg effect params (dummy)",
1929
+ size: 16,
1649
1930
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1650
1931
  })
1651
1932
  this.compositeBindGroupLayout = this.device.createBindGroupLayout({
@@ -1662,6 +1943,9 @@ export class Engine {
1662
1943
  { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1663
1944
  // 360 backdrop equirect (PhotoDome-style skybox) — 1×1 fallback when unset.
1664
1945
  { binding: 6, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1946
+ // User background-effect params — dummy buffer when no effect is set. The
1947
+ // layout is explicit, so the base shader legally ignores the binding.
1948
+ { binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1665
1949
  ],
1666
1950
  })
1667
1951
  this.fallbackEquirectTexture = this.device.createTexture({
@@ -1672,29 +1956,15 @@ export class Engine {
1672
1956
  })
1673
1957
  this.fallbackEquirectView = this.fallbackEquirectTexture.createView()
1674
1958
 
1959
+ this.compositePipelineLayout = this.device.createPipelineLayout({
1960
+ bindGroupLayouts: [this.compositeBindGroupLayout],
1961
+ })
1675
1962
  const compositeShader = this.device.createShaderModule({
1676
1963
  label: "composite shader",
1677
- code: COMPOSITE_SHADER_WGSL,
1678
- })
1679
-
1680
- const compositePipelineLayout = this.device.createPipelineLayout({
1681
- bindGroupLayouts: [this.compositeBindGroupLayout],
1964
+ code: buildCompositeShader(null),
1682
1965
  })
1683
- const makeCompositePipeline = (applyGamma: boolean, label: string): GPURenderPipeline =>
1684
- this.device.createRenderPipeline({
1685
- label,
1686
- layout: compositePipelineLayout,
1687
- vertex: { module: compositeShader, entryPoint: "vs" },
1688
- fragment: {
1689
- module: compositeShader,
1690
- entryPoint: "fs",
1691
- constants: { APPLY_GAMMA: applyGamma ? 1 : 0 },
1692
- targets: [{ format: this.presentationFormat }],
1693
- },
1694
- primitive: { topology: "triangle-list" },
1695
- })
1696
- this.compositePipelineIdentity = makeCompositePipeline(false, "composite pipeline (gamma=1)")
1697
- this.compositePipelineGamma = makeCompositePipeline(true, "composite pipeline (gamma!=1)")
1966
+ this.compositePipelineIdentity = this.makeCompositePipeline(compositeShader, false, "composite pipeline (gamma=1)")
1967
+ this.compositePipelineGamma = this.makeCompositePipeline(compositeShader, true, "composite pipeline (gamma!=1)")
1698
1968
 
1699
1969
  // GPU vertex-morph compute pipeline (shared by all models; per-model bind groups).
1700
1970
  // Bindings: 0-4 read-only storage (base pos, CSR rowStart/colMorph/colOffset, weights),
@@ -2613,6 +2883,7 @@ export class Engine {
2613
2883
  if (!shared) {
2614
2884
  tex.destroy()
2615
2885
  this.textureCache.delete(path)
2886
+ this.textureAlphaCache.delete(path)
2616
2887
  }
2617
2888
  }
2618
2889
  for (const buf of inst.gpuBuffers) {
@@ -3181,11 +3452,18 @@ export class Engine {
3181
3452
  // 1-based so that (0,0) = clear color = "no hit"
3182
3453
  const modelId = this.modelInstances.size + 1
3183
3454
 
3455
+ const texLogicalPath = (texIndex: number): string | null =>
3456
+ texIndex < 0 || texIndex >= textures.length
3457
+ ? null
3458
+ : joinAssetPath(inst.basePath, normalizeAssetPath(textures[texIndex].path))
3184
3459
  const loadTextureByIndex = async (texIndex: number): Promise<GPUTexture | null> => {
3185
- if (texIndex < 0 || texIndex >= textures.length) return null
3186
- const logicalPath = joinAssetPath(inst.basePath, normalizeAssetPath(textures[texIndex].path))
3187
- return this.createTextureFromLogicalPath(inst, logicalPath)
3460
+ const logicalPath = texLogicalPath(texIndex)
3461
+ return logicalPath ? this.createTextureFromLogicalPath(inst, logicalPath) : null
3188
3462
  }
3463
+ // Mesh data for sheerness sampling (8 floats/vertex; uv at +6). See
3464
+ // materialIsSheer — classification happens per material below.
3465
+ const meshVertices = model.getVertices()
3466
+ const meshIndices = model.getIndices()
3189
3467
 
3190
3468
  // 頭 bone index for the eye shader's rear-view gate (-1 when absent).
3191
3469
  const headBoneIndex = model.getSkeleton().bones.findIndex((b) => b.name === "頭")
@@ -3204,7 +3482,18 @@ export class Engine {
3204
3482
  }
3205
3483
 
3206
3484
  const materialAlpha = mat.diffuse[3]
3207
- const isTransparent = materialAlpha < 1.0 - 0.001
3485
+ // Transparent bucket when the MATERIAL says so — or when the TEXTURE does
3486
+ // (sheer cloth almost always ships with diffuse alpha 1.0 and carries its
3487
+ // translucency in texture alpha). Transparent-bucket draws happen after
3488
+ // the opaque bucket (and after the late-drawn hair render-class), so a
3489
+ // veil composites over the hair behind it instead of depth-rejecting it;
3490
+ // they are also excluded from the shadow map, so sheer cloth stops
3491
+ // casting the solid shadow of an opaque sheet.
3492
+ const diffusePath = texLogicalPath(mat.diffuseTextureIndex)
3493
+ const alphaSampler = diffusePath ? this.textureAlphaCache.get(diffusePath) : null
3494
+ const isTransparent =
3495
+ materialAlpha < 1.0 - 0.001 ||
3496
+ materialIsSheer(meshVertices, meshIndices, currentIndexOffset, indexCount, alphaSampler)
3208
3497
 
3209
3498
  // Sphere map (sph=1 multiply / spa=2 add). Mode 3 (sub-texture UV) is
3210
3499
  // rare and not implemented — treated as none, like a failed load.
@@ -3403,6 +3692,10 @@ export class Engine {
3403
3692
  }
3404
3693
  }
3405
3694
 
3695
+ // CPU alpha sampler for sheerness classification (see textureAlphaCache).
3696
+ // Canvas 2D premultiplies RGB on readback, but the ALPHA channel is exact.
3697
+ this.textureAlphaCache.set(cacheKey, buildAlphaSampler(source, rgba, width, height))
3698
+
3406
3699
  const mipLevelCount = Math.floor(Math.log2(Math.max(width, height))) + 1
3407
3700
  const texture = this.device.createTexture({
3408
3701
  label: `texture: ${cacheKey}`,
@@ -4705,7 +4998,7 @@ export class Engine {
4705
4998
  // is LEFT-HANDED (+Z forward, see Mat4.lookAtInto), so the world-space
4706
4999
  // right/up/FORWARD vectors are rows 0/1/2 of its rotation block directly
4707
5000
  // (column-major storage: row i = values[i], values[i+4], values[i+8]).
4708
- if (this.backdropEquirectView && this.compositeUniformBuffer) {
5001
+ if ((this.backdropEquirectView || this.backgroundEffect) && this.compositeUniformBuffer) {
4709
5002
  const v = viewMatrix.values
4710
5003
  const u = this.compositeUniformData
4711
5004
  const tanHalf = Math.tan((this.camera.fov ?? Math.PI / 4) / 2)
@@ -4722,6 +5015,10 @@ export class Engine {
4722
5015
  u[21] = v[6]
4723
5016
  u[22] = v[10]
4724
5017
  u[23] = 0
5018
+ // Effect clock + canvas size (viewU[6]) — written on the same refresh.
5019
+ u[24] = (performance.now() - this.bgEffectEpochMs) / 1000
5020
+ u[26] = this.canvas.width
5021
+ u[27] = this.canvas.height
4725
5022
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
4726
5023
  }
4727
5024
  }
package/src/index.ts CHANGED
@@ -13,6 +13,8 @@ export {
13
13
  type GizmoDragEvent,
14
14
  type GizmoDragCallback,
15
15
  type GizmoDragKind,
16
+ type BackgroundEffectParamValue,
17
+ type BackgroundEffectResult,
16
18
  } from "./engine"
17
19
  export { parsePmxFolderInput, pmxFileAtRelativePath, type PmxFolderInputResult } from "./folder-upload"
18
20
  export {
@@ -1,7 +1,34 @@
1
1
  // Composite: HDR scene + bloom pyramid → Filmic tone map → gamma → swapchain.
2
2
  // Bloom tint/intensity applied at combine (EEVEE treats them as combine-stage params, not prefilter).
3
+ //
4
+ // The shader is a TEMPLATE: buildCompositeShader() emits either the base pass or
5
+ // a variant with a user background effect injected (setBackgroundEffect). The
6
+ // effect is background mode 3, a sibling of the 360 equirect (mode 2) — it reuses
7
+ // the same per-pixel view-ray reconstruction and composites in display space
8
+ // under the scene, so it never affects lighting, bloom, or tonemapping.
3
9
 
4
- export const COMPOSITE_SHADER_WGSL = /* wgsl */ `
10
+ /** What a user background effect must define, documented once:
11
+ *
12
+ * fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f
13
+ *
14
+ * - `ray` — normalized world-space view direction of this pixel (left-handed,
15
+ * +Z forward; identical to what the 360 skybox samples by).
16
+ * - `uv` — 0..1 across the canvas, origin bottom-left (shadertoy-style).
17
+ * - `time` — seconds since the effect was applied.
18
+ * - `bgResolution()` — canvas size in pixels, for aspect correction.
19
+ * - declared params arrive as `params.<name>` (f32 or vec3f).
20
+ * Return display-space sRGB + alpha, 0..1. The effect is a LAYER: it is
21
+ * over-composited onto the base background (solid color / 360 equirect /
22
+ * transparent) and sits behind the scene — alpha 0 lets the base show
23
+ * through, so e.g. a starfield returns stars with a transparent sky. */
24
+ export type CompositeEffectSource = {
25
+ /** User WGSL defining `background(...)` (plus any helpers it wants). */
26
+ wgsl: string
27
+ /** Codegen'd `struct BgParams {...}` + binding decl; empty when no params. */
28
+ paramsDecl: string
29
+ }
30
+
31
+ const COMPOSITE_HEAD = /* wgsl */ `
5
32
  // Pipeline-override constant: the engine creates two composite pipelines, one
6
33
  // with APPLY_GAMMA=false (gamma=1 fast path) and one with APPLY_GAMMA=true.
7
34
  // The 'if (APPLY_GAMMA)' below is resolved at pipeline-compile time — the
@@ -12,7 +39,7 @@ override APPLY_GAMMA: bool = true;
12
39
  @group(0) @binding(0) var hdrTex: texture_2d<f32>;
13
40
  @group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)
14
41
  @group(0) @binding(2) var bloomSamp: sampler;
15
- @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 6>;
42
+ @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 7>;
16
43
  // Aux mask/alpha texture. .r = bloom mask (unused here; bloom blit uses it).
17
44
  // .g = accumulated canvas alpha (what hdr.a carried before the HDR format
18
45
  // became rg11b10ufloat). We unpremultiply HDR by this alpha for tonemap, then
@@ -29,10 +56,12 @@ override APPLY_GAMMA: bool = true;
29
56
  @group(0) @binding(5) var filmicLut: texture_2d<f32>;
30
57
  // viewU[0] = (exposure, invGamma, _, _); viewU[1] = (tint.rgb, intensity)
31
58
  // viewU[2] = (background.rgb, mode) — display-space sRGB, composited UNDER the
32
- // scene post-tonemap. mode: 0 transparent (DOM shows), 1 solid color,
33
- // 2 = 360 equirect skybox sampled by view ray.
59
+ // scene post-tonemap. BASE-layer mode: 0 transparent (DOM shows),
60
+ // 1 solid color, 2 = 360 equirect skybox sampled by view ray. A user
61
+ // WGSL effect is a separate LAYER over the base (viewU[6].y flag).
34
62
  // viewU[3] = (camera right, tanHalfFov·aspect); viewU[4] = (camera up, tanHalfFov);
35
- // viewU[5] = (camera forward, _) — refreshed per frame while the skybox is active.
63
+ // viewU[5] = (camera forward, _) — refreshed per frame while skybox/effect active.
64
+ // viewU[6] = (time seconds, effect on/off, canvas width, canvas height).
36
65
  // invGamma = 1/gamma precomputed on CPU — avoids a per-pixel divide.
37
66
  @group(0) @binding(6) var bgEquirect: texture_2d<f32>;
38
67
 
@@ -50,6 +79,11 @@ fn filmic(x: f32) -> f32 {
50
79
  return textureSampleLevel(filmicLut, bloomSamp, vec2f(u, 0.5), 0.0).r;
51
80
  }
52
81
 
82
+ /** Canvas size in pixels — for user background effects (aspect correction). */
83
+ fn bgResolution() -> vec2f { return viewU[6].zw; }
84
+ `
85
+
86
+ const COMPOSITE_BODY = /* wgsl */ `
53
87
  @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
54
88
  let x = f32((vi & 1u) << 2u) - 1.0;
55
89
  let y = f32((vi & 2u) << 1u) - 1.0;
@@ -76,21 +110,60 @@ fn filmic(x: f32) -> f32 {
76
110
  if (APPLY_GAMMA) {
77
111
  disp = pow(disp, vec3f(viewU[0].y));
78
112
  }
79
- // Composite over the background in display space (premultiplied out).
113
+ // Composite over the background in display space (premultiplied out). The
114
+ // background is TWO layers: a base (transparent / solid color / 360 equirect)
115
+ // and an optional user WGSL effect over-composited onto it.
80
116
  let bg = viewU[2];
81
- var bgRgb = bg.rgb;
82
117
  var bgA = select(0.0, 1.0, bg.w > 0.5);
83
- if (bg.w > 1.5) {
84
- // 360 equirect: rebuild this pixel's world-space view ray from the camera
85
- // basis, then latitude/longitude-map into the panorama. The dome sits at
86
- // infinity (no parallax) PhotoDome-style, display-only.
118
+ var bgPm = bg.rgb * bgA; // premultiplied accumulator
119
+ let fxOn = viewU[6].y > 0.5;
120
+ if (bg.w > 1.5 || fxOn) {
121
+ // The equirect and any effect both need this pixel's world-space view ray,
122
+ // rebuilt from the camera basis. The dome sits at infinity (no parallax) —
123
+ // PhotoDome-style, display-only.
87
124
  let ndc = vec2f(fragCoord.x / fullSz.x * 2.0 - 1.0, 1.0 - fragCoord.y / fullSz.y * 2.0);
88
125
  let dir = normalize(viewU[5].xyz + ndc.x * viewU[3].w * viewU[3].xyz + ndc.y * viewU[4].w * viewU[4].xyz);
89
- // LH world (+Z forward): longitude = atan2(x, z), Babylon-PhotoDome convention.
90
- let su = 0.5 + atan2(dir.x, dir.z) * 0.15915494309; // 1/(2π)
91
- let sv = 0.5 - asin(clamp(dir.y, -1.0, 1.0)) * 0.31830988618; // 1
92
- bgRgb = textureSampleLevel(bgEquirect, bloomSamp, vec2f(su, sv), 0.0).rgb;
126
+ if (bg.w > 1.5) {
127
+ // LH world (+Z forward): longitude = atan2(x, z), Babylon-PhotoDome convention.
128
+ let su = 0.5 + atan2(dir.x, dir.z) * 0.15915494309; // 1/(2π)
129
+ let sv = 0.5 - asin(clamp(dir.y, -1.0, 1.0)) * 0.31830988618; // 1/π
130
+ bgPm = textureSampleLevel(bgEquirect, bloomSamp, vec2f(su, sv), 0.0).rgb;
131
+ }
132
+ BG_EFFECT_CALL
93
133
  }
94
- return vec4f(disp * alpha + bgRgb * bgA * (1.0 - alpha), alpha + bgA * (1.0 - alpha));
134
+ return vec4f(disp * alpha + bgPm * (1.0 - alpha), alpha + bgA * (1.0 - alpha));
95
135
  }
96
136
  `
137
+
138
+ // Base variant: no effect installed, the flag is never set — the ray block only
139
+ // runs for the equirect, and there is nothing to add. (`dir` may go unused when
140
+ // this compiles with mode<2 shaders; WGSL is fine with an unused let.)
141
+ const NO_EFFECT_CALL = `_ = dir;`
142
+
143
+ // uv flipped to bottom-left origin (shadertoy convention); clamped so a stray
144
+ // effect can't push negatives/NaN into the premultiplied composite. Standard
145
+ // OVER onto the base layer.
146
+ const EFFECT_CALL = /* wgsl */ `
147
+ if (fxOn) {
148
+ let bgUv = vec2f(fragCoord.x / fullSz.x, 1.0 - fragCoord.y / fullSz.y);
149
+ let fx = clamp(background(dir, bgUv, viewU[6].x), vec4f(0.0), vec4f(1.0));
150
+ bgPm = fx.rgb * fx.a + bgPm * (1.0 - fx.a);
151
+ bgA = fx.a + bgA * (1.0 - fx.a);
152
+ }
153
+ `
154
+
155
+ export function buildCompositeShader(effect?: CompositeEffectSource | null): string {
156
+ if (!effect) return COMPOSITE_HEAD + COMPOSITE_BODY.replace("BG_EFFECT_CALL", NO_EFFECT_CALL)
157
+ return (
158
+ COMPOSITE_HEAD +
159
+ "\n// ── user background effect (setBackgroundEffect) ──\n" +
160
+ effect.paramsDecl +
161
+ "\n" +
162
+ effect.wgsl +
163
+ "\n" +
164
+ COMPOSITE_BODY.replace("BG_EFFECT_CALL", EFFECT_CALL.trim())
165
+ )
166
+ }
167
+
168
+ /** Kept for compatibility with existing imports (the base, no-effect shader). */
169
+ export const COMPOSITE_SHADER_WGSL = buildCompositeShader(null)
@@ -67,6 +67,14 @@ struct FSOut { @location(0) color: vec4f, @location(1) mask: vec4f };
67
67
  }
68
68
  }
69
69
  vis *= 0.04;
70
+ // Outside the light's frustum there IS no shadow information — the clamped
71
+ // border samples above compare against unrelated depths and read "shadowed",
72
+ // darkening the whole far plane with a visible band at the frustum edge
73
+ // (masked by the opaque surface normally, glaring in green-screen mode where
74
+ // only the shadow-catcher term renders). Fade to fully lit near the border.
75
+ let inZ = select(0.0, 1.0, ndc.z > 0.0 && ndc.z < 1.0);
76
+ let frustum = (1.0 - smoothstep(0.88, 0.96, abs(ndc.x))) * (1.0 - smoothstep(0.88, 0.96, abs(ndc.y))) * inZ;
77
+ vis = mix(1.0, vis, frustum);
70
78
 
71
79
  // Frosted/matte micro-texture
72
80
  let noiseVal = fbmNoise(i.worldPos.xz * 3.0);