reze-engine 0.40.0 → 0.41.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/dist/engine.d.ts +87 -31
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +264 -77
- package/dist/graph/presets/body.js +2 -2
- package/dist/graph/presets/cloth_rough.js +2 -2
- package/dist/graph/presets/cloth_smooth.js +2 -2
- package/dist/graph/presets/default.js +2 -2
- package/dist/graph/presets/eye.js +2 -2
- package/dist/graph/presets/face.js +2 -2
- package/dist/graph/presets/hair.js +2 -2
- package/dist/graph/presets/metal.js +2 -2
- package/dist/graph/presets/stockings.js +3 -3
- package/dist/graph/registry.d.ts.map +1 -1
- package/dist/graph/registry.js +436 -10
- package/dist/graph/style-group.d.ts +46 -0
- package/dist/graph/style-group.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/shaders/agx-lut.d.ts +11 -0
- package/dist/shaders/agx-lut.d.ts.map +1 -0
- package/dist/shaders/agx-lut.js +29 -0
- package/dist/shaders/materials/common.d.ts +1 -1
- package/dist/shaders/materials/common.d.ts.map +1 -1
- package/dist/shaders/materials/common.js +10 -0
- package/dist/shaders/materials/nodes.d.ts +1 -1
- package/dist/shaders/materials/nodes.d.ts.map +1 -1
- package/dist/shaders/materials/nodes.js +957 -555
- package/dist/shaders/passes/composite.d.ts +27 -12
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +155 -48
- package/package.json +1 -1
- package/src/engine.ts +304 -84
- package/src/graph/presets/body.ts +2 -2
- package/src/graph/presets/cloth_rough.ts +2 -2
- package/src/graph/presets/cloth_smooth.ts +2 -2
- package/src/graph/presets/default.ts +2 -2
- package/src/graph/presets/eye.ts +2 -2
- package/src/graph/presets/face.ts +2 -2
- package/src/graph/presets/hair.ts +2 -2
- package/src/graph/presets/metal.ts +2 -2
- package/src/graph/presets/stockings.ts +3 -3
- package/src/graph/registry.ts +450 -10
- package/src/graph/style-group.ts +44 -0
- package/src/index.ts +4 -2
- package/src/shaders/agx-lut.ts +30 -0
- package/src/shaders/materials/common.ts +10 -0
- package/src/shaders/materials/nodes.ts +962 -560
- package/src/shaders/passes/composite.ts +182 -64
package/dist/engine.js
CHANGED
|
@@ -17,6 +17,7 @@ import { TRANSPARENT_DEPTH_PREPASS_WGSL } from "./shaders/passes/depth-prepass";
|
|
|
17
17
|
import { SELECTION_MASK_SHADER_WGSL, SELECTION_EDGE_SHADER_WGSL } from "./shaders/passes/selection";
|
|
18
18
|
import { GIZMO_SHADER_WGSL } from "./shaders/passes/gizmo";
|
|
19
19
|
import { BLOOM_BLIT_SHADER_WGSL, BLOOM_DOWNSAMPLE_SHADER_WGSL, BLOOM_UPSAMPLE_SHADER_WGSL, } from "./shaders/passes/bloom";
|
|
20
|
+
import { AGX_LUT_GZ, AGX_LUT_SIZE } from "./shaders/agx-lut";
|
|
20
21
|
import { buildCompositeShader } from "./shaders/passes/composite";
|
|
21
22
|
import { PICK_SHADER_WGSL } from "./shaders/passes/pick";
|
|
22
23
|
import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap";
|
|
@@ -46,7 +47,17 @@ const PRESET_NAME_HINTS = [
|
|
|
46
47
|
"face",
|
|
47
48
|
["顔", "颜", "顏", "脸", "臉", "かお", "face", "舌", "tongue", "牙", "牙齿", "齿", "歯", "teeth", "tooth", "口腔", "口内", "mouth", "嘴", "唇", "歯茎", "gums"],
|
|
48
49
|
],
|
|
49
|
-
|
|
50
|
+
// Simplified 发 is listed as compounds, never bare: it also writes 发光 (glow),
|
|
51
|
+
// and hair carries a renderClass, so a chance hit puts an emissive panel in the
|
|
52
|
+
// hair pass. Same reasoning as bare 口 being omitted from face above.
|
|
53
|
+
[
|
|
54
|
+
"hair",
|
|
55
|
+
[
|
|
56
|
+
"前髪", "後髪", "髪", "髮", "頭髪", "もみあげ", "アホ毛", "ヘア",
|
|
57
|
+
"头发", "前发", "后发", "长发", "短发", "发丝", "刘海", "辫", "马尾",
|
|
58
|
+
"hair", "ahoge", "bang",
|
|
59
|
+
],
|
|
60
|
+
],
|
|
50
61
|
["body", ["肌", "皮肤", "skin"]],
|
|
51
62
|
["metal", ["金属", "メタル", "metal", "earring", "耳环", "耳環"]],
|
|
52
63
|
[
|
|
@@ -159,7 +170,7 @@ export const DEFAULT_DEPTH_OF_FIELD_OPTIONS = {
|
|
|
159
170
|
export const DEFAULT_VIEW_TRANSFORM = {
|
|
160
171
|
exposure: 0.6,
|
|
161
172
|
gamma: 1.0,
|
|
162
|
-
|
|
173
|
+
transform: "filmic",
|
|
163
174
|
};
|
|
164
175
|
const NEUTRAL_GRADE_CHANNEL = 0.5;
|
|
165
176
|
export const DEFAULT_COLOR_GRADING = {
|
|
@@ -302,19 +313,24 @@ export class Engine {
|
|
|
302
313
|
/** Depth-only view of the scene's MSAA depth buffer, read by the DoF gather. */
|
|
303
314
|
this.depthReadView = null;
|
|
304
315
|
// [exposure, invGamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
|
|
305
|
-
|
|
316
|
+
// 11 × vec4f — see the viewU comment in composite.ts. The last one is the
|
|
317
|
+
// camera's world position, which is what lets a foreground effect turn the
|
|
318
|
+
// depth it is handed into a PLACE (bgWorldPos) rather than a distance.
|
|
319
|
+
this.compositeUniformData = new Float32Array(44);
|
|
306
320
|
/** Composite background (display-space sRGB 0–1) — null = transparent canvas. */
|
|
307
321
|
this.backgroundColor = null;
|
|
308
322
|
// 360 backdrop (equirectangular skybox, sampled by view ray in composite).
|
|
309
323
|
this.backdropEquirectTexture = null;
|
|
310
324
|
this.backdropEquirectView = null;
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
325
|
+
// The scene's user WGSL effect (setEffect). ONE per scene, mounted under the
|
|
326
|
+
// scene, over it, or both — whichever of background()/foreground() the code
|
|
327
|
+
// defines. The composite pipelines are REBUILT with the user code injected;
|
|
328
|
+
// params live in their own uniform buffer so setEffectParam is a write, not a
|
|
314
329
|
// recompile (the same instant tier as setStyleParam).
|
|
315
|
-
this.
|
|
316
|
-
|
|
317
|
-
|
|
330
|
+
this.effect = null;
|
|
331
|
+
this.agxLutTexture = null;
|
|
332
|
+
/** time=0 origin for the active effect — reset each setEffect. */
|
|
333
|
+
this.effectEpochMs = 0;
|
|
318
334
|
this.compositeBloomView = null;
|
|
319
335
|
this.bloomBlitUniformData = new Float32Array(4);
|
|
320
336
|
this.bloomUpsampleUniformData = new Float32Array(4);
|
|
@@ -649,7 +665,7 @@ export class Engine {
|
|
|
649
665
|
return {
|
|
650
666
|
exposure: partial?.exposure ?? d.exposure,
|
|
651
667
|
gamma: partial?.gamma ?? d.gamma,
|
|
652
|
-
|
|
668
|
+
transform: partial?.transform ?? d.transform,
|
|
653
669
|
};
|
|
654
670
|
}
|
|
655
671
|
/** Current bloom settings (Blender names; tint is a copied `Vec3`). */
|
|
@@ -667,7 +683,7 @@ export class Engine {
|
|
|
667
683
|
}
|
|
668
684
|
getViewTransformOptions() {
|
|
669
685
|
const v = this.viewTransform;
|
|
670
|
-
return { exposure: v.exposure, gamma: v.gamma,
|
|
686
|
+
return { exposure: v.exposure, gamma: v.gamma, transform: v.transform };
|
|
671
687
|
}
|
|
672
688
|
/**
|
|
673
689
|
* Color-grade the tonemapped scene (ASC CDL slope/offset/power + saturation).
|
|
@@ -707,8 +723,8 @@ export class Engine {
|
|
|
707
723
|
v.exposure = patch.exposure;
|
|
708
724
|
if (patch.gamma !== undefined)
|
|
709
725
|
v.gamma = patch.gamma;
|
|
710
|
-
if (patch.
|
|
711
|
-
v.
|
|
726
|
+
if (patch.transform !== undefined)
|
|
727
|
+
v.transform = patch.transform;
|
|
712
728
|
if (this.device && this.compositeUniformBuffer) {
|
|
713
729
|
this.writeCompositeViewUniforms();
|
|
714
730
|
}
|
|
@@ -737,10 +753,12 @@ export class Engine {
|
|
|
737
753
|
u[8] = bg?.x ?? 0;
|
|
738
754
|
u[9] = bg?.y ?? 0;
|
|
739
755
|
u[10] = bg?.z ?? 0;
|
|
740
|
-
// Base-layer mode
|
|
741
|
-
//
|
|
756
|
+
// Base-layer mode only. A user effect is a separate LAYER over whichever
|
|
757
|
+
// base is active, and needs no flag of its own: the composite pipeline is
|
|
758
|
+
// rebuilt per effect, so the compiled variant IS the flag.
|
|
742
759
|
u[11] = this.backdropEquirectView ? 2 : bg ? 1 : 0;
|
|
743
|
-
|
|
760
|
+
// Which display transform forms the frame (see viewTransform in composite.ts).
|
|
761
|
+
u[25] = v.transform === "agx" ? 2 : v.transform === "standard" ? 1 : 0;
|
|
744
762
|
u[26] = this.canvas.width;
|
|
745
763
|
u[27] = this.canvas.height;
|
|
746
764
|
// ── Grade (viewU[7..9]) ── The UI's three tonal COLORS map to ASC CDL here,
|
|
@@ -799,9 +817,10 @@ export class Engine {
|
|
|
799
817
|
{ binding: 4, resource: this.maskResolveView },
|
|
800
818
|
{ binding: 5, resource: this.filmicLutView },
|
|
801
819
|
{ binding: 6, resource: this.backdropEquirectView ?? this.fallbackEquirectView },
|
|
802
|
-
{ binding: 7, resource: { buffer: this.
|
|
820
|
+
{ binding: 7, resource: { buffer: this.effect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
|
|
803
821
|
{ binding: 8, resource: this.depthReadView },
|
|
804
822
|
{ binding: 9, resource: { buffer: this.dofUniformBuffer } },
|
|
823
|
+
{ binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
|
|
805
824
|
],
|
|
806
825
|
});
|
|
807
826
|
}
|
|
@@ -864,42 +883,71 @@ export class Engine {
|
|
|
864
883
|
});
|
|
865
884
|
}
|
|
866
885
|
/**
|
|
867
|
-
* Install
|
|
868
|
-
*
|
|
869
|
-
*
|
|
870
|
-
* or transparency) — its alpha lets the base show through, so a starfield is
|
|
871
|
-
* stars over the user's background color. Display-space: never affects
|
|
872
|
-
* lighting, bloom, or tonemapping, and is captured by offline export like any
|
|
873
|
-
* background.
|
|
874
|
-
*
|
|
875
|
-
* `wgsl` must define:
|
|
886
|
+
* Install the scene's WGSL effect (shadertoy-style), rendered per-pixel in the
|
|
887
|
+
* composite pass. ONE effect per scene, and the code says where it mounts by
|
|
888
|
+
* which of these it defines — either, or both in one file:
|
|
876
889
|
*
|
|
877
890
|
* fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f
|
|
891
|
+
* fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f
|
|
892
|
+
*
|
|
893
|
+
* `background` is a LAYER between the base background and the scene,
|
|
894
|
+
* over-composited onto whichever base is active (solid color, 360 equirect, or
|
|
895
|
+
* transparency) — its alpha lets the base show through, so a starfield is
|
|
896
|
+
* stars over the user's background color. `foreground` composites over the
|
|
897
|
+
* finished frame instead, which is where rain, snow, petals and fog live, and
|
|
898
|
+
* is handed `depth`: the camera-space distance in metres of whatever the scene
|
|
899
|
+
* drew at that pixel (the far plane where it drew nothing). Compare a
|
|
900
|
+
* particle's own distance against it and the model occludes it; fog just reads
|
|
901
|
+
* it, since fog's alpha IS a function of distance.
|
|
878
902
|
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
*
|
|
903
|
+
* `ray` is the pixel's normalized world-space view direction (LH, +Z forward —
|
|
904
|
+
* what the skybox samples by), `uv` is 0..1 bottom-left origin, `time` is
|
|
905
|
+
* seconds since apply, and `bgResolution()` gives the canvas size. Return sRGB
|
|
906
|
+
* + alpha; alpha is the only "how much does this replace" control there is.
|
|
907
|
+
* Declared `params` arrive as `params.<name>` (number → f32, Vec3 → vec3f),
|
|
908
|
+
* shared by both mounts, and are later tweaked without recompiling via
|
|
909
|
+
* setEffectParam.
|
|
885
910
|
*
|
|
886
|
-
*
|
|
887
|
-
*
|
|
888
|
-
*
|
|
911
|
+
* Both mounts are display-space: neither affects lighting, bloom or
|
|
912
|
+
* tonemapping, and both are captured by offline export. A foreground makes the
|
|
913
|
+
* scene pass STORE its depth buffer (it otherwise discards it into tile
|
|
914
|
+
* memory) for as long as one is installed.
|
|
915
|
+
*
|
|
916
|
+
* Compiles off the hot path (async pipelines): on failure the previous effect
|
|
917
|
+
* is KEPT and diagnostics are returned with line numbers relative to the
|
|
918
|
+
* user's WGSL. Pass null to remove the effect.
|
|
889
919
|
*/
|
|
890
|
-
async
|
|
920
|
+
async setEffect(wgsl, params) {
|
|
921
|
+
const noMounts = { background: false, foreground: false };
|
|
891
922
|
if (!this.device)
|
|
892
|
-
return { ok: false, diagnostics: ["
|
|
923
|
+
return { ok: false, diagnostics: ["setEffect requires init() to have run"], mounts: noMounts };
|
|
893
924
|
if (wgsl === null) {
|
|
894
|
-
this.
|
|
895
|
-
this.
|
|
925
|
+
this.effect?.paramsBuffer?.destroy();
|
|
926
|
+
this.effect = null;
|
|
896
927
|
const module = this.device.createShaderModule({ label: "composite shader", code: buildCompositeShader(null) });
|
|
897
928
|
this.compositePipelineIdentity = this.makeCompositePipeline(module, false, "composite pipeline (gamma=1)");
|
|
898
929
|
this.compositePipelineGamma = this.makeCompositePipeline(module, true, "composite pipeline (gamma!=1)");
|
|
899
930
|
this.rebuildCompositeBindGroup();
|
|
900
931
|
this.writeCompositeViewUniforms();
|
|
901
|
-
return { ok: true, diagnostics: [] };
|
|
932
|
+
return { ok: true, diagnostics: [], mounts: noMounts };
|
|
902
933
|
}
|
|
934
|
+
// ── Which mounts did the author ask for? A declaration, not a setting: the
|
|
935
|
+
// entry points present in the source are the ones compiled in. Matching the
|
|
936
|
+
// `fn` keyword is enough to be safe against a `foreground` LOCAL or a call
|
|
937
|
+
// to one — those never follow `fn`.
|
|
938
|
+
const hasBackground = /\bfn\s+background\s*\(/.test(wgsl);
|
|
939
|
+
const hasForeground = /\bfn\s+foreground\s*\(/.test(wgsl);
|
|
940
|
+
if (!hasBackground && !hasForeground) {
|
|
941
|
+
return {
|
|
942
|
+
ok: false,
|
|
943
|
+
diagnostics: [
|
|
944
|
+
"an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f " +
|
|
945
|
+
"or fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f (or both)",
|
|
946
|
+
],
|
|
947
|
+
mounts: noMounts,
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
const mounts = { background: hasBackground, foreground: hasForeground };
|
|
903
951
|
// ── Params: codegen a WGSL struct and mirror its uniform layout on the CPU.
|
|
904
952
|
// Fields are emitted in declaration order; offsets follow WGSL's natural
|
|
905
953
|
// uniform rules (f32 align 4, vec3f align 16 size 12), computed identically
|
|
@@ -910,7 +958,7 @@ export class Engine {
|
|
|
910
958
|
let cursor = 0;
|
|
911
959
|
for (const [name, value] of entries) {
|
|
912
960
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
913
|
-
return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`] };
|
|
961
|
+
return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`], mounts };
|
|
914
962
|
}
|
|
915
963
|
const isVec = typeof value !== "number";
|
|
916
964
|
const align = isVec ? 16 : 4;
|
|
@@ -931,14 +979,14 @@ export class Engine {
|
|
|
931
979
|
}
|
|
932
980
|
}
|
|
933
981
|
const paramsDecl = entries.length
|
|
934
|
-
? `struct
|
|
982
|
+
? `struct EffectParams {\n${fields.join("\n")}\n}\n@group(0) @binding(7) var<uniform> params: EffectParams;\n`
|
|
935
983
|
: "";
|
|
936
984
|
// ── Compile with validation captured, not thrown at the console. Line
|
|
937
985
|
// numbers in diagnostics are rebased to the USER's source.
|
|
938
|
-
const source = buildCompositeShader({ wgsl, paramsDecl });
|
|
986
|
+
const source = buildCompositeShader({ wgsl, paramsDecl, hasBackground, hasForeground });
|
|
939
987
|
const userLineOffset = source.slice(0, source.indexOf(wgsl)).split("\n").length - 1;
|
|
940
988
|
this.device.pushErrorScope("validation");
|
|
941
|
-
const module = this.device.createShaderModule({ label: "composite shader (
|
|
989
|
+
const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source });
|
|
942
990
|
const info = await module.getCompilationInfo();
|
|
943
991
|
const scopeErr = await this.device.popErrorScope();
|
|
944
992
|
const diagnostics = info.messages
|
|
@@ -947,7 +995,7 @@ export class Engine {
|
|
|
947
995
|
if (diagnostics.length === 0 && scopeErr)
|
|
948
996
|
diagnostics.push(scopeErr.message);
|
|
949
997
|
if (diagnostics.length > 0)
|
|
950
|
-
return { ok: false, diagnostics };
|
|
998
|
+
return { ok: false, diagnostics, mounts };
|
|
951
999
|
let identity;
|
|
952
1000
|
let gamma;
|
|
953
1001
|
try {
|
|
@@ -964,36 +1012,40 @@ export class Engine {
|
|
|
964
1012
|
primitive: { topology: "triangle-list" },
|
|
965
1013
|
});
|
|
966
1014
|
[identity, gamma] = await Promise.all([
|
|
967
|
-
make(false, "composite pipeline (
|
|
968
|
-
make(true, "composite pipeline (
|
|
1015
|
+
make(false, "composite pipeline (effect, gamma=1)"),
|
|
1016
|
+
make(true, "composite pipeline (effect, gamma!=1)"),
|
|
969
1017
|
]);
|
|
970
1018
|
}
|
|
971
1019
|
catch (e) {
|
|
972
|
-
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)] };
|
|
1020
|
+
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts };
|
|
973
1021
|
}
|
|
974
1022
|
// ── Swap — only now does the old effect (and its params buffer) go away.
|
|
975
|
-
this.
|
|
1023
|
+
this.effect?.paramsBuffer?.destroy();
|
|
976
1024
|
let paramsBuffer = null;
|
|
977
1025
|
if (entries.length) {
|
|
978
1026
|
paramsBuffer = this.device.createBuffer({
|
|
979
|
-
label: "
|
|
1027
|
+
label: "effect params",
|
|
980
1028
|
size: paramsData.byteLength,
|
|
981
1029
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
982
1030
|
});
|
|
983
1031
|
this.device.queue.writeBuffer(paramsBuffer, 0, paramsData);
|
|
984
1032
|
}
|
|
985
|
-
this.
|
|
1033
|
+
this.effect = { wgsl, paramLayout: layout, paramsBuffer, paramsData, hasBackground, hasForeground };
|
|
986
1034
|
this.compositePipelineIdentity = identity;
|
|
987
1035
|
this.compositePipelineGamma = gamma;
|
|
988
|
-
this.
|
|
1036
|
+
this.effectEpochMs = performance.now();
|
|
989
1037
|
this.rebuildCompositeBindGroup();
|
|
990
1038
|
this.writeCompositeViewUniforms();
|
|
991
|
-
return { ok: true, diagnostics: [] };
|
|
1039
|
+
return { ok: true, diagnostics: [], mounts };
|
|
1040
|
+
}
|
|
1041
|
+
/** Which mounts the installed effect declared. Both false when none is set. */
|
|
1042
|
+
getEffectMounts() {
|
|
1043
|
+
return { background: this.effect?.hasBackground ?? false, foreground: this.effect?.hasForeground ?? false };
|
|
992
1044
|
}
|
|
993
|
-
/** Write one
|
|
994
|
-
*
|
|
995
|
-
|
|
996
|
-
const fx = this.
|
|
1045
|
+
/** Write one effect param (declared at setEffect) — a uniform write, no
|
|
1046
|
+
* recompile; the instant tier, like setStyleParam. */
|
|
1047
|
+
setEffectParam(name, value) {
|
|
1048
|
+
const fx = this.effect;
|
|
997
1049
|
if (!fx || !fx.paramsBuffer)
|
|
998
1050
|
return;
|
|
999
1051
|
const slot = fx.paramLayout.get(name);
|
|
@@ -1073,7 +1125,10 @@ export class Engine {
|
|
|
1073
1125
|
return;
|
|
1074
1126
|
const d = this.depthOfField;
|
|
1075
1127
|
const u = this.dofUniformData;
|
|
1076
|
-
|
|
1128
|
+
// `d.enabled &&`, because a foreground effect also drives this write (for
|
|
1129
|
+
// projA/projB alone) and auto-focus walks every visible character's bones —
|
|
1130
|
+
// work nothing would read with the gather switched off.
|
|
1131
|
+
const auto = d.enabled && d.focusMode === "auto" ? this.getModelBodyFocus() : null;
|
|
1077
1132
|
u[0] = d.enabled ? 1 : 0;
|
|
1078
1133
|
u[1] = auto?.distance ?? Math.max(d.focusDistance, 0.05);
|
|
1079
1134
|
// In auto mode the authored range is a floor — the sharp band never cuts
|
|
@@ -1247,6 +1302,38 @@ export class Engine {
|
|
|
1247
1302
|
// smooth gradients) while still passing through every anchor (look preserved) and staying
|
|
1248
1303
|
// monotone (no tonemap overshoot/ringing). Domain is uniform in log2 space: anchor k sits
|
|
1249
1304
|
// at t=k, k=0..13 (t = log2(linear)+10). See composite.ts::filmic for the sampling map.
|
|
1305
|
+
/**
|
|
1306
|
+
* Decompress and upload Blender's AgX cube.
|
|
1307
|
+
*
|
|
1308
|
+
* Deliberately off the critical path: it is 723 KB once inflated, and a frame
|
|
1309
|
+
* rendered before it lands should show the scene under whatever transform is
|
|
1310
|
+
* already there rather than wait. Until then binding 10 holds a 1×1×1 stand-in,
|
|
1311
|
+
* which is only ever sampled if someone selects AgX in that window.
|
|
1312
|
+
*/
|
|
1313
|
+
async loadAgxLut() {
|
|
1314
|
+
try {
|
|
1315
|
+
const packed = Uint8Array.from(atob(AGX_LUT_GZ), (ch) => ch.charCodeAt(0));
|
|
1316
|
+
const stream = new Blob([packed]).stream().pipeThrough(new DecompressionStream("gzip"));
|
|
1317
|
+
const bytes = new Uint8Array(await new Response(stream).arrayBuffer());
|
|
1318
|
+
const n = AGX_LUT_SIZE;
|
|
1319
|
+
if (bytes.byteLength !== n * n * n * 4)
|
|
1320
|
+
throw new Error(`AgX LUT is ${bytes.byteLength} bytes, expected ${n ** 3 * 4}`);
|
|
1321
|
+
const tex = this.device.createTexture({
|
|
1322
|
+
label: "AgX 57³ LUT",
|
|
1323
|
+
size: [n, n, n],
|
|
1324
|
+
dimension: "3d",
|
|
1325
|
+
format: "rgb10a2unorm",
|
|
1326
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
1327
|
+
});
|
|
1328
|
+
// .cube order is red fastest, which is exactly a 3D texture's own layout.
|
|
1329
|
+
this.device.queue.writeTexture({ texture: tex }, bytes, { bytesPerRow: n * 4, rowsPerImage: n }, [n, n, n]);
|
|
1330
|
+
this.agxLutTexture = tex;
|
|
1331
|
+
this.rebuildCompositeBindGroup();
|
|
1332
|
+
}
|
|
1333
|
+
catch {
|
|
1334
|
+
// A missing LUT costs AgX, not the renderer — the other transforms stand.
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1250
1337
|
bakeFilmicLut() {
|
|
1251
1338
|
const anchors = [
|
|
1252
1339
|
0.0028, 0.0068, 0.0151, 0.0313, 0.061, 0.112, 0.192, 0.306, 0.459, 0.631, 0.82, 0.907, 0.962, 0.989,
|
|
@@ -1492,6 +1579,13 @@ export class Engine {
|
|
|
1492
1579
|
// StyleUniforms for compiled graph shaders (adjust-tier sliders). Hand-written
|
|
1493
1580
|
// presets simply don't declare it — a layout may carry bindings a shader ignores.
|
|
1494
1581
|
{ binding: 4, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
1582
|
+
// Style-group image maps. A PMX material carries one image; a
|
|
1583
|
+
// Blender-authored look needs a lightmap or ramp beside it, and those
|
|
1584
|
+
// belong to the GROUP rather than to the model's own material data.
|
|
1585
|
+
{ binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
1586
|
+
{ binding: 6, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
1587
|
+
{ binding: 7, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
1588
|
+
{ binding: 8, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
1495
1589
|
],
|
|
1496
1590
|
});
|
|
1497
1591
|
// Shared zero StyleUniforms buffer — bound by every ungrouped material; grouped
|
|
@@ -1619,6 +1713,14 @@ export class Engine {
|
|
|
1619
1713
|
this.shadowMapDepthView = this.shadowMapTexture.createView();
|
|
1620
1714
|
// One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
|
|
1621
1715
|
this.bakeBrdfLut();
|
|
1716
|
+
this.agxFallbackTexture = this.device.createTexture({
|
|
1717
|
+
label: "AgX LUT fallback",
|
|
1718
|
+
size: [1, 1, 1],
|
|
1719
|
+
dimension: "3d",
|
|
1720
|
+
format: "rgb10a2unorm",
|
|
1721
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
1722
|
+
});
|
|
1723
|
+
void this.loadAgxLut();
|
|
1622
1724
|
this.bakeFilmicLut();
|
|
1623
1725
|
// Now that shadow resources exist, create the main per-frame bind group
|
|
1624
1726
|
this.perFrameBindGroup = this.device.createBindGroup({
|
|
@@ -1891,11 +1993,12 @@ export class Engine {
|
|
|
1891
1993
|
// mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
|
|
1892
1994
|
this.compositeUniformBuffer = this.device.createBuffer({
|
|
1893
1995
|
label: "composite view uniforms",
|
|
1894
|
-
//
|
|
1996
|
+
// 11 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
|
|
1895
1997
|
// (bg rgb, mode) · camera right/up/forward basis for the 360 skybox ray ·
|
|
1896
|
-
// (time, _, canvas width, canvas height) for user
|
|
1897
|
-
//
|
|
1898
|
-
|
|
1998
|
+
// (time, _, canvas width, canvas height) for user effects · three grade
|
|
1999
|
+
// vectors (CDL offset+contrast, power+saturation, slope+flag) · camera
|
|
2000
|
+
// world position, for an effect placing itself in the scene.
|
|
2001
|
+
size: 176,
|
|
1899
2002
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1900
2003
|
});
|
|
1901
2004
|
this.dofUniformBuffer = this.device.createBuffer({
|
|
@@ -1935,6 +2038,9 @@ export class Engine {
|
|
|
1935
2038
|
texture: { sampleType: "depth", viewDimension: "2d", multisampled: true },
|
|
1936
2039
|
},
|
|
1937
2040
|
{ binding: 9, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
2041
|
+
// AgX's 57³ cube. Decompressed and uploaded off the critical path, so a
|
|
2042
|
+
// 1×1×1 stand-in keeps the bind group valid until it arrives.
|
|
2043
|
+
{ binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { viewDimension: "3d" } },
|
|
1938
2044
|
],
|
|
1939
2045
|
});
|
|
1940
2046
|
this.fallbackEquirectTexture = this.device.createTexture({
|
|
@@ -2536,6 +2642,12 @@ export class Engine {
|
|
|
2536
2642
|
hasCameraVmd() {
|
|
2537
2643
|
return this.cameraAnimation !== null;
|
|
2538
2644
|
}
|
|
2645
|
+
/** Seconds the loaded camera VMD runs for — its last keyframe — or 0 with none
|
|
2646
|
+
* loaded. A timeline cannot draw a lane to scale without it, and the camera's
|
|
2647
|
+
* length is its own: it does not have to match any model's clip. */
|
|
2648
|
+
getCameraVmdDuration() {
|
|
2649
|
+
return this.cameraAnimation?.duration ?? 0;
|
|
2650
|
+
}
|
|
2539
2651
|
/** Drop the loaded camera VMD and return to orbit control. */
|
|
2540
2652
|
clearCameraVmd() {
|
|
2541
2653
|
this.cameraAnimation = null;
|
|
@@ -2740,7 +2852,7 @@ export class Engine {
|
|
|
2740
2852
|
// zero buffer is engine-owned.
|
|
2741
2853
|
this.forEachInstance((inst) => {
|
|
2742
2854
|
for (const install of inst.styleGroups.values())
|
|
2743
|
-
|
|
2855
|
+
this.destroyInstall(install);
|
|
2744
2856
|
inst.styleGroups.clear();
|
|
2745
2857
|
});
|
|
2746
2858
|
this.zeroStyleBuffer?.destroy();
|
|
@@ -2866,7 +2978,7 @@ export class Engine {
|
|
|
2866
2978
|
}
|
|
2867
2979
|
// Per-group StyleUniforms buffers aren't in gpuBuffers (allocated post-load).
|
|
2868
2980
|
for (const install of inst.styleGroups.values())
|
|
2869
|
-
|
|
2981
|
+
this.destroyInstall(install);
|
|
2870
2982
|
this.modelInstances.delete(name);
|
|
2871
2983
|
}
|
|
2872
2984
|
getModelNames() {
|
|
@@ -2948,11 +3060,50 @@ export class Engine {
|
|
|
2948
3060
|
}
|
|
2949
3061
|
// Build a material's bind group with binding(4) pointing at a given StyleUniforms buffer
|
|
2950
3062
|
// (the group's buffer when grouped, or the shared zero buffer when ungrouped).
|
|
2951
|
-
|
|
3063
|
+
/** A group's uniform buffer and its maps have the same lifetime — freeing one
|
|
3064
|
+
* without the other is how a re-apply leaks GPU memory a frame at a time. */
|
|
3065
|
+
destroyInstall(install) {
|
|
3066
|
+
install.uniformBuffer.destroy();
|
|
3067
|
+
for (const tex of install.images ?? [])
|
|
3068
|
+
tex?.destroy();
|
|
3069
|
+
}
|
|
3070
|
+
/** Upload a group's image maps. Sources are decoded images the host already
|
|
3071
|
+
* holds; the engine never fetches, matching how models and motions arrive. */
|
|
3072
|
+
uploadGroupImages(group) {
|
|
3073
|
+
if (!group.images?.length)
|
|
3074
|
+
return undefined;
|
|
3075
|
+
return group.images.slice(0, 4).map((entry) => {
|
|
3076
|
+
if (!entry)
|
|
3077
|
+
return null;
|
|
3078
|
+
const wrapped = "source" in entry;
|
|
3079
|
+
const src = wrapped ? entry.source : entry;
|
|
3080
|
+
const width = Math.max(1, "naturalWidth" in src ? src.naturalWidth : src.width);
|
|
3081
|
+
const height = Math.max(1, "naturalHeight" in src ? src.naturalHeight : src.height);
|
|
3082
|
+
const tex = this.device.createTexture({
|
|
3083
|
+
label: `group map: ${group.id}`,
|
|
3084
|
+
size: [width, height],
|
|
3085
|
+
// Colour maps decode to linear on sample, the way material textures do;
|
|
3086
|
+
// data maps must not, or every threshold packed in their channels moves.
|
|
3087
|
+
format: wrapped && entry.srgb ? "rgba8unorm-srgb" : "rgba8unorm",
|
|
3088
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
3089
|
+
});
|
|
3090
|
+
this.device.queue.copyExternalImageToTexture({ source: src }, { texture: tex, premultipliedAlpha: wrapped && entry.premultiplied === true }, [width, height]);
|
|
3091
|
+
return tex;
|
|
3092
|
+
});
|
|
3093
|
+
}
|
|
3094
|
+
createMaterialBindGroup(label, baseEntries, styleBuffer, groupImages) {
|
|
3095
|
+
// Every material bind group in the engine is built here, which is why the
|
|
3096
|
+
// group's maps are threaded through this one function rather than patched in
|
|
3097
|
+
// at each call site — an unset slot reads white, never stale.
|
|
3098
|
+
const slots = [];
|
|
3099
|
+
for (let i = 0; i < 4; i++) {
|
|
3100
|
+
const tex = groupImages?.[i] ?? this.fallbackMaterialTexture;
|
|
3101
|
+
slots.push({ binding: 5 + i, resource: tex.createView() });
|
|
3102
|
+
}
|
|
2952
3103
|
return this.device.createBindGroup({
|
|
2953
3104
|
label,
|
|
2954
3105
|
layout: this.mainPerMaterialBindGroupLayout,
|
|
2955
|
-
entries: [...baseEntries, { binding: 4, resource: { buffer: styleBuffer } }],
|
|
3106
|
+
entries: [...baseEntries, { binding: 4, resource: { buffer: styleBuffer } }, ...slots],
|
|
2956
3107
|
});
|
|
2957
3108
|
}
|
|
2958
3109
|
setMaterialVisible(modelName, materialName, visible) {
|
|
@@ -4305,12 +4456,20 @@ export class Engine {
|
|
|
4305
4456
|
this.updateCameraUniforms();
|
|
4306
4457
|
this.updateShadowLightVP();
|
|
4307
4458
|
// Depth of field's entire disabled cost is this branch: depth stays in
|
|
4308
|
-
// TBDR tile memory (discard) unless the composite
|
|
4309
|
-
// frame.
|
|
4310
|
-
//
|
|
4459
|
+
// TBDR tile memory (discard) unless something in the composite reads it this
|
|
4460
|
+
// frame. Two things can — the DoF gather, and the depth handed to a
|
|
4461
|
+
// foreground effect — and either one makes the pass store it.
|
|
4462
|
+
//
|
|
4463
|
+
// The uniform refresh is shared for the same reason: linearDepth() inverts
|
|
4464
|
+
// the z-buffer with projA/projB out of dofU[2], which track the camera's
|
|
4465
|
+
// near/far and so must be rewritten every frame either reader is live. A
|
|
4466
|
+
// foreground with a stale pair would read metres from the wrong frustum. The
|
|
4467
|
+
// write leaves dofU[0].x at 0 while DoF is off, so refreshing it does not
|
|
4468
|
+
// switch the gather on.
|
|
4311
4469
|
const dofOn = this.depthOfField.enabled;
|
|
4312
|
-
|
|
4313
|
-
|
|
4470
|
+
const depthRead = dofOn || (this.effect?.hasForeground ?? false);
|
|
4471
|
+
this.renderPassDescriptor.depthStencilAttachment.depthStoreOp = depthRead ? "store" : "discard";
|
|
4472
|
+
if (depthRead)
|
|
4314
4473
|
this.writeDepthOfFieldUniforms();
|
|
4315
4474
|
const encoder = this.device.createCommandEncoder();
|
|
4316
4475
|
// GPU vertex morphs: write morphed positions into vertex buffers before any pass reads
|
|
@@ -4523,7 +4682,7 @@ export class Engine {
|
|
|
4523
4682
|
for (const [id, install] of inst.styleGroups) {
|
|
4524
4683
|
if (!nextIds.has(id)) {
|
|
4525
4684
|
inst.styleGroupGen.set(id, (inst.styleGroupGen.get(id) ?? 0) + 1);
|
|
4526
|
-
|
|
4685
|
+
this.destroyInstall(install);
|
|
4527
4686
|
inst.styleGroups.delete(id);
|
|
4528
4687
|
}
|
|
4529
4688
|
}
|
|
@@ -4557,7 +4716,7 @@ export class Engine {
|
|
|
4557
4716
|
if (!inst || !install)
|
|
4558
4717
|
return;
|
|
4559
4718
|
inst.styleGroupGen.set(groupId, (inst.styleGroupGen.get(groupId) ?? 0) + 1); // discard in-flight compile
|
|
4560
|
-
|
|
4719
|
+
this.destroyInstall(install);
|
|
4561
4720
|
inst.styleGroups.delete(groupId);
|
|
4562
4721
|
this.assignDrawCallGroups(inst, this.currentClaims(inst));
|
|
4563
4722
|
}
|
|
@@ -4568,7 +4727,7 @@ export class Engine {
|
|
|
4568
4727
|
return;
|
|
4569
4728
|
for (const [id, install] of inst.styleGroups) {
|
|
4570
4729
|
inst.styleGroupGen.set(id, (inst.styleGroupGen.get(id) ?? 0) + 1);
|
|
4571
|
-
|
|
4730
|
+
this.destroyInstall(install);
|
|
4572
4731
|
}
|
|
4573
4732
|
inst.styleGroups.clear();
|
|
4574
4733
|
this.assignDrawCallGroups(inst, new Map());
|
|
@@ -4657,6 +4816,9 @@ export class Engine {
|
|
|
4657
4816
|
size: 256,
|
|
4658
4817
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
4659
4818
|
});
|
|
4819
|
+
// The outgoing install's maps go with it — a re-apply that changes images
|
|
4820
|
+
// would otherwise strand the old textures for the life of the model.
|
|
4821
|
+
const previousImages = inst.styleGroups.get(group.id)?.images;
|
|
4660
4822
|
inst.styleGroups.set(group.id, {
|
|
4661
4823
|
group,
|
|
4662
4824
|
renderClass,
|
|
@@ -4665,9 +4827,28 @@ export class Engine {
|
|
|
4665
4827
|
pipelineNoDepthWrite,
|
|
4666
4828
|
overEyesPipeline,
|
|
4667
4829
|
uniformBuffer,
|
|
4830
|
+
images: this.uploadGroupImages(group),
|
|
4668
4831
|
slotMap: result.slotMap,
|
|
4669
4832
|
signature,
|
|
4670
4833
|
});
|
|
4834
|
+
// Rebind this group's draw calls before the old textures go.
|
|
4835
|
+
//
|
|
4836
|
+
// assignDrawCallGroups only rebuilds a bind group when a material CHANGES
|
|
4837
|
+
// group, which is the wrong test here: swapping the graph on a group a
|
|
4838
|
+
// material already belongs to leaves its id alone while replacing the maps
|
|
4839
|
+
// underneath it. The draw call then kept a bind group holding the outgoing
|
|
4840
|
+
// textures — destroyed on the next line — so a graph swap either sampled the
|
|
4841
|
+
// old maps, or the fallback white where the previous graph had none (which
|
|
4842
|
+
// reads as a blown-out white material through any screen or add), or tripped
|
|
4843
|
+
// a validation error on a destroyed texture. Only a reload cleared it.
|
|
4844
|
+
const install = inst.styleGroups.get(group.id);
|
|
4845
|
+
for (const dc of inst.drawCalls) {
|
|
4846
|
+
if (!dc.baseBindGroupEntries || dc.groupId !== group.id)
|
|
4847
|
+
continue;
|
|
4848
|
+
dc.bindGroup = this.createMaterialBindGroup(`material: ${dc.materialName}`, dc.baseBindGroupEntries, uniformBuffer, install?.images);
|
|
4849
|
+
}
|
|
4850
|
+
for (const tex of previousImages ?? [])
|
|
4851
|
+
tex?.destroy();
|
|
4671
4852
|
this.writeGroupDefaults(uniformBuffer, group, result.slotMap);
|
|
4672
4853
|
return { ok: true, diagnostics, slotMap: result.slotMap };
|
|
4673
4854
|
}
|
|
@@ -4686,7 +4867,7 @@ export class Engine {
|
|
|
4686
4867
|
if (dc.groupId === groupId)
|
|
4687
4868
|
continue;
|
|
4688
4869
|
dc.groupId = groupId;
|
|
4689
|
-
dc.bindGroup = this.createMaterialBindGroup(`material: ${dc.materialName}`, dc.baseBindGroupEntries, install ? install.uniformBuffer : this.zeroStyleBuffer);
|
|
4870
|
+
dc.bindGroup = this.createMaterialBindGroup(`material: ${dc.materialName}`, dc.baseBindGroupEntries, install ? install.uniformBuffer : this.zeroStyleBuffer, install?.images);
|
|
4690
4871
|
}
|
|
4691
4872
|
this.sortDrawCalls(inst);
|
|
4692
4873
|
}
|
|
@@ -4938,7 +5119,7 @@ export class Engine {
|
|
|
4938
5119
|
// is LEFT-HANDED (+Z forward, see Mat4.lookAtInto), so the world-space
|
|
4939
5120
|
// right/up/FORWARD vectors are rows 0/1/2 of its rotation block directly
|
|
4940
5121
|
// (column-major storage: row i = values[i], values[i+4], values[i+8]).
|
|
4941
|
-
if ((this.backdropEquirectView || this.
|
|
5122
|
+
if ((this.backdropEquirectView || this.effect) && this.compositeUniformBuffer) {
|
|
4942
5123
|
const v = viewMatrix.values;
|
|
4943
5124
|
const u = this.compositeUniformData;
|
|
4944
5125
|
const tanHalf = Math.tan((this.camera.fov ?? Math.PI / 4) / 2);
|
|
@@ -4956,9 +5137,15 @@ export class Engine {
|
|
|
4956
5137
|
u[22] = v[10];
|
|
4957
5138
|
u[23] = 0;
|
|
4958
5139
|
// Effect clock + canvas size (viewU[6]) — written on the same refresh.
|
|
4959
|
-
u[24] = (performance.now() - this.
|
|
5140
|
+
u[24] = (performance.now() - this.effectEpochMs) / 1000;
|
|
4960
5141
|
u[26] = this.canvas.width;
|
|
4961
5142
|
u[27] = this.canvas.height;
|
|
5143
|
+
// Camera world position (viewU[10]) — the other half of bgWorldPos. It
|
|
5144
|
+
// rides this refresh rather than writeCompositeViewUniforms because it
|
|
5145
|
+
// changes every frame the camera does, exactly like the basis above.
|
|
5146
|
+
u[40] = cameraPos.x;
|
|
5147
|
+
u[41] = cameraPos.y;
|
|
5148
|
+
u[42] = cameraPos.z;
|
|
4962
5149
|
this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u);
|
|
4963
5150
|
}
|
|
4964
5151
|
}
|
|
@@ -52,7 +52,7 @@ export const BODY_GRAPH = {
|
|
|
52
52
|
{
|
|
53
53
|
id: "principled",
|
|
54
54
|
type: "principled",
|
|
55
|
-
inputs: { metallic: 0.0,
|
|
55
|
+
inputs: { metallic: 0.0, specular_ior_level: 0.5, roughness: 0.3, spec_clamp: 10.0, sheen_weight: 0.0, sheen_tint: 0.0 },
|
|
56
56
|
},
|
|
57
57
|
{ id: "p_sum", type: "add_shader" },
|
|
58
58
|
{ id: "mix_shader_001", type: "mix_shader", inputs: { fac: 0.5 } },
|
|
@@ -89,7 +89,7 @@ export const BODY_GRAPH = {
|
|
|
89
89
|
{ from: { node: "noise_ramp", socket: "fac_out" }, to: { node: "principled_base", socket: "fac" } },
|
|
90
90
|
{ from: { node: "bc", socket: "color" }, to: { node: "principled_base", socket: "a" } },
|
|
91
91
|
{ from: { node: "bc", socket: "color" }, to: { node: "p_emit", socket: "color" } },
|
|
92
|
-
{ from: { node: "principled_base", socket: "color" }, to: { node: "principled", socket: "
|
|
92
|
+
{ from: { node: "principled_base", socket: "color" }, to: { node: "principled", socket: "base_color" } },
|
|
93
93
|
{ from: { node: "bump", socket: "vector" }, to: { node: "principled", socket: "normal" } },
|
|
94
94
|
{ from: { node: "principled", socket: "color" }, to: { node: "p_sum", socket: "a" } },
|
|
95
95
|
{ from: { node: "p_emit", socket: "color" }, to: { node: "p_sum", socket: "b" } },
|