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/dist/engine.d.ts +49 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +318 -31
- package/dist/gpu-profile.d.ts +19 -0
- package/dist/gpu-profile.d.ts.map +1 -0
- package/dist/gpu-profile.js +120 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/physics/profile.d.ts +18 -0
- package/dist/physics/profile.d.ts.map +1 -0
- package/dist/physics/profile.js +44 -0
- package/dist/shaders/passes/composite.d.ts +23 -1
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +62 -16
- package/dist/shaders/passes/ground.d.ts +1 -1
- package/dist/shaders/passes/ground.d.ts.map +1 -1
- package/dist/shaders/passes/ground.js +8 -0
- package/package.json +1 -1
- package/src/engine.ts +346 -32
- package/src/index.ts +2 -0
- package/src/shaders/passes/composite.ts +89 -16
- package/src/shaders/passes/ground.ts +8 -0
- package/dist/physics-debug.d.ts +0 -30
- package/dist/physics-debug.d.ts.map +0 -1
- package/dist/physics-debug.js +0 -526
- package/dist/shaders/passes/physics-debug.d.ts +0 -2
- package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
- package/dist/shaders/passes/physics-debug.js +0 -69
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 {
|
|
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(
|
|
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
|
-
//
|
|
1647
|
-
// (bg rgb, mode) · camera right/up/forward basis for the 360 skybox ray
|
|
1648
|
-
|
|
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:
|
|
1964
|
+
code: buildCompositeShader(null),
|
|
1678
1965
|
})
|
|
1679
|
-
|
|
1680
|
-
|
|
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
|
-
|
|
3175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>,
|
|
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),
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
//
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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 +
|
|
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)
|