reze-engine 0.24.0 → 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"
@@ -80,7 +80,7 @@ const PRESET_NAME_HINTS: Array<[MaterialPreset, string[]]> = [
80
80
  // share the face material family. Bare 口 is omitted — it collides with 袖口 (cuff).
81
81
  [
82
82
  "face",
83
- ["顔", "颜", "脸", "かお", "face", "舌", "tongue", "牙", "牙齿", "歯", "teeth", "tooth", "口腔", "口内", "mouth", "嘴", "歯茎", "gums"],
83
+ ["顔", "颜", "顏", "脸", "臉", "かお", "face", "舌", "tongue", "牙", "牙齿", "歯", "teeth", "tooth", "口腔", "口内", "mouth", "嘴", "歯茎", "gums"],
84
84
  ],
85
85
  ["hair", ["前髪", "後髪", "髪", "髮", "头发", "頭髪", "もみあげ", "アホ毛", "ヘア", "hair", "ahoge", "bang"]],
86
86
  ["body", ["肌", "皮肤", "skin"]],
@@ -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,
1964
+ code: buildCompositeShader(null),
1678
1965
  })
1679
-
1680
- const compositePipelineLayout = this.device.createPipelineLayout({
1681
- bindGroupLayouts: [this.compositeBindGroupLayout],
1682
- })
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),
@@ -2599,9 +2869,21 @@ export class Engine {
2599
2869
  inst.model.stopAnimation()
2600
2870
  for (const path of inst.textureCacheKeys) {
2601
2871
  const tex = this.textureCache.get(path)
2602
- if (tex) {
2872
+ if (!tex) continue
2873
+ // The texture cache is shared across models — destroy only when no OTHER
2874
+ // live instance references the key, else the survivor submits with a
2875
+ // destroyed texture.
2876
+ let shared = false
2877
+ for (const other of this.modelInstances.values()) {
2878
+ if (other !== inst && other.textureCacheKeys.includes(path)) {
2879
+ shared = true
2880
+ break
2881
+ }
2882
+ }
2883
+ if (!shared) {
2603
2884
  tex.destroy()
2604
2885
  this.textureCache.delete(path)
2886
+ this.textureAlphaCache.delete(path)
2605
2887
  }
2606
2888
  }
2607
2889
  for (const buf of inst.gpuBuffers) {
@@ -3170,11 +3452,18 @@ export class Engine {
3170
3452
  // 1-based so that (0,0) = clear color = "no hit"
3171
3453
  const modelId = this.modelInstances.size + 1
3172
3454
 
3455
+ const texLogicalPath = (texIndex: number): string | null =>
3456
+ texIndex < 0 || texIndex >= textures.length
3457
+ ? null
3458
+ : joinAssetPath(inst.basePath, normalizeAssetPath(textures[texIndex].path))
3173
3459
  const loadTextureByIndex = async (texIndex: number): Promise<GPUTexture | null> => {
3174
- if (texIndex < 0 || texIndex >= textures.length) return null
3175
- const logicalPath = joinAssetPath(inst.basePath, normalizeAssetPath(textures[texIndex].path))
3176
- return this.createTextureFromLogicalPath(inst, logicalPath)
3460
+ const logicalPath = texLogicalPath(texIndex)
3461
+ return logicalPath ? this.createTextureFromLogicalPath(inst, logicalPath) : null
3177
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()
3178
3467
 
3179
3468
  // 頭 bone index for the eye shader's rear-view gate (-1 when absent).
3180
3469
  const headBoneIndex = model.getSkeleton().bones.findIndex((b) => b.name === "頭")
@@ -3193,7 +3482,18 @@ export class Engine {
3193
3482
  }
3194
3483
 
3195
3484
  const materialAlpha = mat.diffuse[3]
3196
- 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)
3197
3497
 
3198
3498
  // Sphere map (sph=1 multiply / spa=2 add). Mode 3 (sub-texture UV) is
3199
3499
  // rare and not implemented — treated as none, like a failed load.
@@ -3339,6 +3639,12 @@ export class Engine {
3339
3639
  const cacheKey = logicalPath
3340
3640
  const cached = this.textureCache.get(cacheKey)
3341
3641
  if (cached) {
3642
+ // Record the reference on THIS instance too — the cache is engine-global
3643
+ // (two PMX in one folder share texture paths), and removeModel decides
3644
+ // destruction by who still references a key. Without this, a cache-hit
3645
+ // borrower kept rendering a texture the creator's removal destroyed
3646
+ // ("Destroyed texture used in a submit").
3647
+ if (!inst.textureCacheKeys.includes(cacheKey)) inst.textureCacheKeys.push(cacheKey)
3342
3648
  return cached
3343
3649
  }
3344
3650
 
@@ -3386,6 +3692,10 @@ export class Engine {
3386
3692
  }
3387
3693
  }
3388
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
+
3389
3699
  const mipLevelCount = Math.floor(Math.log2(Math.max(width, height))) + 1
3390
3700
  const texture = this.device.createTexture({
3391
3701
  label: `texture: ${cacheKey}`,
@@ -4688,7 +4998,7 @@ export class Engine {
4688
4998
  // is LEFT-HANDED (+Z forward, see Mat4.lookAtInto), so the world-space
4689
4999
  // right/up/FORWARD vectors are rows 0/1/2 of its rotation block directly
4690
5000
  // (column-major storage: row i = values[i], values[i+4], values[i+8]).
4691
- if (this.backdropEquirectView && this.compositeUniformBuffer) {
5001
+ if ((this.backdropEquirectView || this.backgroundEffect) && this.compositeUniformBuffer) {
4692
5002
  const v = viewMatrix.values
4693
5003
  const u = this.compositeUniformData
4694
5004
  const tanHalf = Math.tan((this.camera.fov ?? Math.PI / 4) / 2)
@@ -4705,6 +5015,10 @@ export class Engine {
4705
5015
  u[21] = v[6]
4706
5016
  u[22] = v[10]
4707
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
4708
5022
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
4709
5023
  }
4710
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)