reze-engine 0.10.2 → 0.11.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/README.md +72 -13
- package/dist/engine.d.ts +170 -34
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +1080 -308
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/shaders/body.d.ts +2 -0
- package/dist/shaders/body.d.ts.map +1 -0
- package/dist/shaders/body.js +209 -0
- package/dist/shaders/classify.d.ts +4 -0
- package/dist/shaders/classify.d.ts.map +1 -0
- package/dist/shaders/classify.js +12 -0
- package/dist/shaders/cloth_rough.d.ts +2 -0
- package/dist/shaders/cloth_rough.d.ts.map +1 -0
- package/dist/shaders/cloth_rough.js +172 -0
- package/dist/shaders/cloth_smooth.d.ts +2 -0
- package/dist/shaders/cloth_smooth.d.ts.map +1 -0
- package/dist/shaders/cloth_smooth.js +171 -0
- package/dist/shaders/default.d.ts +2 -0
- package/dist/shaders/default.d.ts.map +1 -0
- package/dist/shaders/default.js +168 -0
- package/dist/shaders/dfg_lut.d.ts +4 -0
- package/dist/shaders/dfg_lut.d.ts.map +1 -0
- package/dist/shaders/dfg_lut.js +125 -0
- package/dist/shaders/eye.d.ts +2 -0
- package/dist/shaders/eye.d.ts.map +1 -0
- package/dist/shaders/eye.js +142 -0
- package/dist/shaders/face.d.ts +2 -0
- package/dist/shaders/face.d.ts.map +1 -0
- package/dist/shaders/face.js +211 -0
- package/dist/shaders/hair.d.ts +2 -0
- package/dist/shaders/hair.d.ts.map +1 -0
- package/dist/shaders/hair.js +186 -0
- package/dist/shaders/ltc_mag_lut.d.ts +3 -0
- package/dist/shaders/ltc_mag_lut.d.ts.map +1 -0
- package/dist/shaders/ltc_mag_lut.js +1033 -0
- package/dist/shaders/metal.d.ts +2 -0
- package/dist/shaders/metal.d.ts.map +1 -0
- package/dist/shaders/metal.js +171 -0
- package/dist/shaders/nodes.d.ts +2 -0
- package/dist/shaders/nodes.d.ts.map +1 -0
- package/dist/shaders/nodes.js +423 -0
- package/dist/shaders/stockings.d.ts +2 -0
- package/dist/shaders/stockings.d.ts.map +1 -0
- package/dist/shaders/stockings.js +229 -0
- package/package.json +1 -1
- package/src/engine.ts +1281 -376
- package/src/index.ts +12 -2
- package/src/shaders/body.ts +211 -0
- package/src/shaders/classify.ts +25 -0
- package/src/shaders/cloth_rough.ts +174 -0
- package/src/shaders/cloth_smooth.ts +173 -0
- package/src/shaders/default.ts +169 -0
- package/src/shaders/dfg_lut.ts +127 -0
- package/src/shaders/eye.ts +143 -0
- package/src/shaders/face.ts +213 -0
- package/src/shaders/hair.ts +188 -0
- package/src/shaders/ltc_mag_lut.ts +1035 -0
- package/src/shaders/metal.ts +173 -0
- package/src/shaders/nodes.ts +424 -0
- package/src/shaders/stockings.ts +231 -0
package/dist/engine.js
CHANGED
|
@@ -3,17 +3,38 @@ import { Mat4, Vec3 } from "./math";
|
|
|
3
3
|
import { PmxLoader } from "./pmx-loader";
|
|
4
4
|
import { Physics } from "./physics";
|
|
5
5
|
import { createFetchAssetReader, createFileMapAssetReader, deriveBasePathFromPmxPath, fileListToMap, findFirstPmxFileInList, joinAssetPath, normalizeAssetPath, } from "./asset-reader";
|
|
6
|
+
import { DEFAULT_SHADER_WGSL } from "./shaders/default";
|
|
7
|
+
import { DFG_LUT_SIZE, DFG_LUT_WGSL } from "./shaders/dfg_lut";
|
|
8
|
+
import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut";
|
|
9
|
+
import { FACE_SHADER_WGSL } from "./shaders/face";
|
|
10
|
+
import { HAIR_SHADER_WGSL } from "./shaders/hair";
|
|
11
|
+
import { CLOTH_SMOOTH_SHADER_WGSL } from "./shaders/cloth_smooth";
|
|
12
|
+
import { CLOTH_ROUGH_SHADER_WGSL } from "./shaders/cloth_rough";
|
|
13
|
+
import { METAL_SHADER_WGSL } from "./shaders/metal";
|
|
14
|
+
import { BODY_SHADER_WGSL } from "./shaders/body";
|
|
15
|
+
import { EYE_SHADER_WGSL } from "./shaders/eye";
|
|
16
|
+
import { STOCKINGS_SHADER_WGSL } from "./shaders/stockings";
|
|
17
|
+
import { resolvePreset } from "./shaders/classify";
|
|
18
|
+
export const DEFAULT_BLOOM_OPTIONS = {
|
|
19
|
+
enabled: true,
|
|
20
|
+
threshold: 0.5,
|
|
21
|
+
knee: 0.5,
|
|
22
|
+
radius: 4.0,
|
|
23
|
+
color: new Vec3(1.0, 0.7247558832168579, 0.6487361788749695),
|
|
24
|
+
intensity: 0.03,
|
|
25
|
+
clamp: 0.0,
|
|
26
|
+
};
|
|
27
|
+
export const DEFAULT_VIEW_TRANSFORM = {
|
|
28
|
+
exposure: -0.30000001192092896,
|
|
29
|
+
gamma: 1.0,
|
|
30
|
+
look: "medium_high_contrast",
|
|
31
|
+
};
|
|
6
32
|
export const DEFAULT_ENGINE_OPTIONS = {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
rimLightIntensity: 0.4,
|
|
11
|
-
cameraDistance: 26.6,
|
|
12
|
-
cameraTarget: new Vec3(0, 12.5, 0),
|
|
13
|
-
cameraFov: Math.PI / 4,
|
|
33
|
+
world: { color: new Vec3(0.4014, 0.4944, 0.647), strength: 0.3 },
|
|
34
|
+
sun: { color: new Vec3(1.0, 1.0, 1.0), strength: 2.0, direction: new Vec3(-0.0873, -0.3844, 0.919) },
|
|
35
|
+
camera: { distance: 26.6, target: new Vec3(0, 12.5, 0), fov: Math.PI / 4 },
|
|
14
36
|
onRaycast: undefined,
|
|
15
37
|
physicsOptions: { constraintSolverKeywords: ["胸"] },
|
|
16
|
-
shadowLightDirection: new Vec3(0.12, -1, 0.16),
|
|
17
38
|
};
|
|
18
39
|
export class Engine {
|
|
19
40
|
static getInstance() {
|
|
@@ -27,11 +48,19 @@ export class Engine {
|
|
|
27
48
|
this.lightData = new Float32Array(64);
|
|
28
49
|
this.lightCount = 0;
|
|
29
50
|
this.resizeObserver = null;
|
|
51
|
+
// [exposure, gamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
|
|
52
|
+
this.compositeUniformData = new Float32Array(8);
|
|
53
|
+
this.bloomBlitUniformData = new Float32Array(4);
|
|
54
|
+
this.bloomUpsampleUniformData = new Float32Array(4);
|
|
55
|
+
this.bloomMipCount = 0;
|
|
56
|
+
this.bloomDownMipViews = [];
|
|
57
|
+
this.bloomUpMipViews = [];
|
|
58
|
+
this.bloomDownsampleBindGroups = [];
|
|
59
|
+
this.bloomUpsampleBindGroups = [];
|
|
30
60
|
this.hasGround = false;
|
|
31
61
|
this.shadowLightVPMatrix = new Float32Array(16);
|
|
32
62
|
this.groundDrawCall = null;
|
|
33
63
|
this.physicsOptions = DEFAULT_ENGINE_OPTIONS.physicsOptions;
|
|
34
|
-
this.shadowLightDirection = DEFAULT_ENGINE_OPTIONS.shadowLightDirection;
|
|
35
64
|
this.lastTouchTime = 0;
|
|
36
65
|
this.DOUBLE_TAP_DELAY = 300;
|
|
37
66
|
this.pendingPick = null;
|
|
@@ -56,7 +85,7 @@ export class Engine {
|
|
|
56
85
|
};
|
|
57
86
|
this.animationFrameId = null;
|
|
58
87
|
this.renderLoopCallback = null;
|
|
59
|
-
// Shadow
|
|
88
|
+
// Shadow is cast from the visible sun direction — same vector the shader lights with.
|
|
60
89
|
this.shadowLightVPDirty = true;
|
|
61
90
|
this.handleCanvasDoubleClick = (event) => {
|
|
62
91
|
if (!this.onRaycast || this.modelInstances.size === 0)
|
|
@@ -90,20 +119,135 @@ export class Engine {
|
|
|
90
119
|
}
|
|
91
120
|
};
|
|
92
121
|
this.canvas = canvas;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
122
|
+
const d = DEFAULT_ENGINE_OPTIONS;
|
|
123
|
+
this.world = {
|
|
124
|
+
color: options?.world?.color ?? d.world.color,
|
|
125
|
+
strength: options?.world?.strength ?? d.world.strength,
|
|
126
|
+
};
|
|
127
|
+
this.sun = {
|
|
128
|
+
color: options?.sun?.color ?? d.sun.color,
|
|
129
|
+
strength: options?.sun?.strength ?? d.sun.strength,
|
|
130
|
+
direction: options?.sun?.direction ?? d.sun.direction,
|
|
131
|
+
};
|
|
132
|
+
this.cameraConfig = {
|
|
133
|
+
distance: options?.camera?.distance ?? d.camera.distance,
|
|
134
|
+
target: options?.camera?.target ?? d.camera.target,
|
|
135
|
+
fov: options?.camera?.fov ?? d.camera.fov,
|
|
136
|
+
};
|
|
137
|
+
this.onRaycast = options?.onRaycast;
|
|
138
|
+
this.physicsOptions = options?.physicsOptions ?? d.physicsOptions;
|
|
139
|
+
this.bloomSettings = Engine.mergeBloomDefaults(options?.bloom);
|
|
140
|
+
this.viewTransform = Engine.mergeViewTransformDefaults(options?.view);
|
|
141
|
+
}
|
|
142
|
+
/** Merge partial bloom with EEVEE defaults (same as constructor). */
|
|
143
|
+
static mergeBloomDefaults(partial) {
|
|
144
|
+
const d = DEFAULT_BLOOM_OPTIONS;
|
|
145
|
+
const c = partial?.color;
|
|
146
|
+
return {
|
|
147
|
+
enabled: partial?.enabled ?? d.enabled,
|
|
148
|
+
threshold: partial?.threshold ?? d.threshold,
|
|
149
|
+
knee: partial?.knee ?? d.knee,
|
|
150
|
+
radius: partial?.radius ?? d.radius,
|
|
151
|
+
color: c ? new Vec3(c.x, c.y, c.z) : new Vec3(d.color.x, d.color.y, d.color.z),
|
|
152
|
+
intensity: partial?.intensity ?? d.intensity,
|
|
153
|
+
clamp: partial?.clamp ?? d.clamp,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
static mergeViewTransformDefaults(partial) {
|
|
157
|
+
const d = DEFAULT_VIEW_TRANSFORM;
|
|
158
|
+
return {
|
|
159
|
+
exposure: partial?.exposure ?? d.exposure,
|
|
160
|
+
gamma: partial?.gamma ?? d.gamma,
|
|
161
|
+
look: partial?.look ?? d.look,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/** Current bloom settings (Blender names; tint is a copied `Vec3`). */
|
|
165
|
+
getBloomOptions() {
|
|
166
|
+
const b = this.bloomSettings;
|
|
167
|
+
return {
|
|
168
|
+
enabled: b.enabled,
|
|
169
|
+
threshold: b.threshold,
|
|
170
|
+
knee: b.knee,
|
|
171
|
+
radius: b.radius,
|
|
172
|
+
color: new Vec3(b.color.x, b.color.y, b.color.z),
|
|
173
|
+
intensity: b.intensity,
|
|
174
|
+
clamp: b.clamp,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
getViewTransformOptions() {
|
|
178
|
+
const v = this.viewTransform;
|
|
179
|
+
return { exposure: v.exposure, gamma: v.gamma, look: v.look };
|
|
180
|
+
}
|
|
181
|
+
setViewTransformOptions(patch) {
|
|
182
|
+
const v = this.viewTransform;
|
|
183
|
+
if (patch.exposure !== undefined)
|
|
184
|
+
v.exposure = patch.exposure;
|
|
185
|
+
if (patch.gamma !== undefined)
|
|
186
|
+
v.gamma = patch.gamma;
|
|
187
|
+
if (patch.look !== undefined)
|
|
188
|
+
v.look = patch.look;
|
|
189
|
+
if (this.device && this.compositeUniformBuffer) {
|
|
190
|
+
this.writeCompositeViewUniforms();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
writeCompositeViewUniforms() {
|
|
194
|
+
const v = this.viewTransform;
|
|
195
|
+
const b = this.bloomSettings;
|
|
196
|
+
const effIntensity = b.enabled ? b.intensity : 0.0;
|
|
197
|
+
const u = this.compositeUniformData;
|
|
198
|
+
u[0] = v.exposure;
|
|
199
|
+
u[1] = Math.max(v.gamma, 1e-4);
|
|
200
|
+
u[2] = 0.0;
|
|
201
|
+
u[3] = 0.0;
|
|
202
|
+
u[4] = b.color.x;
|
|
203
|
+
u[5] = b.color.y;
|
|
204
|
+
u[6] = b.color.z;
|
|
205
|
+
u[7] = effIntensity;
|
|
206
|
+
this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u);
|
|
207
|
+
}
|
|
208
|
+
/** Patch bloom; GPU uniforms update immediately if `init()` has run. */
|
|
209
|
+
setBloomOptions(patch) {
|
|
210
|
+
const b = this.bloomSettings;
|
|
211
|
+
if (patch.enabled !== undefined)
|
|
212
|
+
b.enabled = patch.enabled;
|
|
213
|
+
if (patch.threshold !== undefined)
|
|
214
|
+
b.threshold = patch.threshold;
|
|
215
|
+
if (patch.knee !== undefined)
|
|
216
|
+
b.knee = patch.knee;
|
|
217
|
+
if (patch.radius !== undefined)
|
|
218
|
+
b.radius = patch.radius;
|
|
219
|
+
if (patch.color !== undefined) {
|
|
220
|
+
b.color.x = patch.color.x;
|
|
221
|
+
b.color.y = patch.color.y;
|
|
222
|
+
b.color.z = patch.color.z;
|
|
223
|
+
}
|
|
224
|
+
if (patch.intensity !== undefined)
|
|
225
|
+
b.intensity = patch.intensity;
|
|
226
|
+
if (patch.clamp !== undefined)
|
|
227
|
+
b.clamp = patch.clamp;
|
|
228
|
+
if (this.device && this.bloomBlitUniformBuffer) {
|
|
229
|
+
this.writeBloomUniforms();
|
|
230
|
+
this.writeCompositeViewUniforms();
|
|
105
231
|
}
|
|
106
232
|
}
|
|
233
|
+
// EEVEE prefilter uniforms (blit stage) + upsample sample scale. Intensity/tint live in composite.
|
|
234
|
+
writeBloomUniforms() {
|
|
235
|
+
const b = this.bloomSettings;
|
|
236
|
+
const bu = this.bloomBlitUniformData;
|
|
237
|
+
// EEVEE prefilter: threshold, knee, clamp (0 → disabled), _unused
|
|
238
|
+
bu[0] = b.threshold;
|
|
239
|
+
bu[1] = b.knee;
|
|
240
|
+
bu[2] = b.clamp;
|
|
241
|
+
bu[3] = 0.0;
|
|
242
|
+
this.device.queue.writeBuffer(this.bloomBlitUniformBuffer, 0, bu);
|
|
243
|
+
const us = this.bloomUpsampleUniformData;
|
|
244
|
+
// Blender: bloom.radius directly controls the tent-filter sample scale in texel units.
|
|
245
|
+
us[0] = Math.max(0.5, b.radius);
|
|
246
|
+
us[1] = 0;
|
|
247
|
+
us[2] = 0;
|
|
248
|
+
us[3] = 0;
|
|
249
|
+
this.device.queue.writeBuffer(this.bloomUpsampleUniformBuffer, 0, us);
|
|
250
|
+
}
|
|
107
251
|
// Step 1: Get WebGPU device and context
|
|
108
252
|
async init() {
|
|
109
253
|
const adapter = await navigator.gpu?.requestAdapter();
|
|
@@ -129,6 +273,69 @@ export class Engine {
|
|
|
129
273
|
this.setupResize();
|
|
130
274
|
Engine.instance = this;
|
|
131
275
|
}
|
|
276
|
+
// One-shot bake of EEVEE's BRDF split-sum DFG LUT — ported from
|
|
277
|
+
// bsdf_lut_frag.glsl. Runs once per engine init; resulting 64×64 rg16float
|
|
278
|
+
// texture is sampled by every material shader via group(0) binding(9).
|
|
279
|
+
bakeDfgLut() {
|
|
280
|
+
this.dfgLutTexture = this.device.createTexture({
|
|
281
|
+
label: "DFG LUT",
|
|
282
|
+
size: [DFG_LUT_SIZE, DFG_LUT_SIZE],
|
|
283
|
+
format: "rg16float",
|
|
284
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
285
|
+
});
|
|
286
|
+
this.dfgLutView = this.dfgLutTexture.createView();
|
|
287
|
+
const module = this.device.createShaderModule({ label: "DFG LUT bake", code: DFG_LUT_WGSL });
|
|
288
|
+
const pipeline = this.device.createRenderPipeline({
|
|
289
|
+
label: "DFG LUT bake pipeline",
|
|
290
|
+
layout: "auto",
|
|
291
|
+
vertex: { module, entryPoint: "vs" },
|
|
292
|
+
fragment: { module, entryPoint: "fs", targets: [{ format: "rg16float" }] },
|
|
293
|
+
primitive: { topology: "triangle-list" },
|
|
294
|
+
});
|
|
295
|
+
const enc = this.device.createCommandEncoder({ label: "DFG LUT bake encoder" });
|
|
296
|
+
const pass = enc.beginRenderPass({
|
|
297
|
+
colorAttachments: [
|
|
298
|
+
{ view: this.dfgLutView, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: "clear", storeOp: "store" },
|
|
299
|
+
],
|
|
300
|
+
});
|
|
301
|
+
pass.setPipeline(pipeline);
|
|
302
|
+
pass.draw(3, 1, 0, 0);
|
|
303
|
+
pass.end();
|
|
304
|
+
this.device.queue.submit([enc.finish()]);
|
|
305
|
+
}
|
|
306
|
+
// Upload Blender's static LTC GGX magnitude LUT (eevee_lut.c ltc_mag_ggx[]).
|
|
307
|
+
// Pairs with the DFG LUT to form ltc_brdf_scale — closure_eval_glossy_lib.glsl:79-81.
|
|
308
|
+
uploadLtcMagLut() {
|
|
309
|
+
this.ltcMagLutTexture = this.device.createTexture({
|
|
310
|
+
label: "LTC mag LUT",
|
|
311
|
+
size: [LTC_MAG_LUT_SIZE, LTC_MAG_LUT_SIZE],
|
|
312
|
+
format: "rg16float",
|
|
313
|
+
usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
|
|
314
|
+
});
|
|
315
|
+
this.ltcMagLutView = this.ltcMagLutTexture.createView();
|
|
316
|
+
// Float32 → float16 bits. rg16float writeTexture expects packed half floats.
|
|
317
|
+
const n = LTC_MAG_LUT_DATA.length;
|
|
318
|
+
const half = new Uint16Array(n);
|
|
319
|
+
const f32 = new Float32Array(1);
|
|
320
|
+
const u32 = new Uint32Array(f32.buffer);
|
|
321
|
+
for (let i = 0; i < n; i++) {
|
|
322
|
+
f32[0] = LTC_MAG_LUT_DATA[i];
|
|
323
|
+
const x = u32[0];
|
|
324
|
+
const sign = (x >>> 16) & 0x8000;
|
|
325
|
+
let exp = ((x >>> 23) & 0xff) - 127 + 15;
|
|
326
|
+
let mant = x & 0x7fffff;
|
|
327
|
+
if (exp <= 0) {
|
|
328
|
+
half[i] = sign; // flush tiny values to signed zero (data here is in [0, ~1])
|
|
329
|
+
}
|
|
330
|
+
else if (exp >= 31) {
|
|
331
|
+
half[i] = sign | 0x7c00; // inf
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
half[i] = sign | (exp << 10) | (mant >>> 13);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
this.device.queue.writeTexture({ texture: this.ltcMagLutTexture }, half, { bytesPerRow: LTC_MAG_LUT_SIZE * 4, rowsPerImage: LTC_MAG_LUT_SIZE }, { width: LTC_MAG_LUT_SIZE, height: LTC_MAG_LUT_SIZE, depthOrArrayLayers: 1 });
|
|
338
|
+
}
|
|
132
339
|
createRenderPipeline(config) {
|
|
133
340
|
return this.device.createRenderPipeline({
|
|
134
341
|
label: config.label,
|
|
@@ -192,8 +399,11 @@ export class Engine {
|
|
|
192
399
|
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
|
|
193
400
|
},
|
|
194
401
|
];
|
|
402
|
+
// Internal scene passes render into the HDR offscreen target; only the final
|
|
403
|
+
// composite pass writes the swapchain. Tonemap moved to composite so bloom
|
|
404
|
+
// (added next) can run on linear HDR.
|
|
195
405
|
const standardBlend = {
|
|
196
|
-
format:
|
|
406
|
+
format: Engine.HDR_FORMAT,
|
|
197
407
|
blend: {
|
|
198
408
|
color: {
|
|
199
409
|
srcFactor: "src-alpha",
|
|
@@ -208,145 +418,59 @@ export class Engine {
|
|
|
208
418
|
},
|
|
209
419
|
};
|
|
210
420
|
const shaderModule = this.device.createShaderModule({
|
|
211
|
-
label: "model
|
|
212
|
-
code:
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
diffuseColor: vec3f,
|
|
238
|
-
_padding3: f32,
|
|
239
|
-
ambientColor: vec3f,
|
|
240
|
-
_padding4: f32,
|
|
241
|
-
specularColor: vec3f,
|
|
242
|
-
_padding5: f32,
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
struct VertexOutput {
|
|
246
|
-
@builtin(position) position: vec4f,
|
|
247
|
-
@location(0) normal: vec3f,
|
|
248
|
-
@location(1) uv: vec2f,
|
|
249
|
-
@location(2) worldPos: vec3f,
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
// group 0: per-frame (bound once per pass)
|
|
253
|
-
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
|
|
254
|
-
@group(0) @binding(1) var<uniform> light: LightUniforms;
|
|
255
|
-
@group(0) @binding(2) var diffuseSampler: sampler;
|
|
256
|
-
// group 1: per-instance (bound once per model)
|
|
257
|
-
@group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
|
|
258
|
-
// group 2: per-material (bound per draw call)
|
|
259
|
-
@group(2) @binding(0) var diffuseTexture: texture_2d<f32>;
|
|
260
|
-
@group(2) @binding(1) var<uniform> material: MaterialUniforms;
|
|
261
|
-
|
|
262
|
-
@vertex fn vs(
|
|
263
|
-
@location(0) position: vec3f,
|
|
264
|
-
@location(1) normal: vec3f,
|
|
265
|
-
@location(2) uv: vec2f,
|
|
266
|
-
@location(3) joints0: vec4<u32>,
|
|
267
|
-
@location(4) weights0: vec4<f32>
|
|
268
|
-
) -> VertexOutput {
|
|
269
|
-
var output: VertexOutput;
|
|
270
|
-
let pos4 = vec4f(position, 1.0);
|
|
271
|
-
|
|
272
|
-
let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
|
|
273
|
-
let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
|
|
274
|
-
let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
|
|
275
|
-
|
|
276
|
-
var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
|
|
277
|
-
var skinnedNrm = vec3f(0.0, 0.0, 0.0);
|
|
278
|
-
for (var i = 0u; i < 4u; i++) {
|
|
279
|
-
let j = joints0[i];
|
|
280
|
-
let w = normalizedWeights[i];
|
|
281
|
-
let m = skinMats[j];
|
|
282
|
-
skinnedPos += (m * pos4) * w;
|
|
283
|
-
let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
|
|
284
|
-
skinnedNrm += (r3 * normal) * w;
|
|
285
|
-
}
|
|
286
|
-
let worldPos = skinnedPos.xyz;
|
|
287
|
-
output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
|
|
288
|
-
output.normal = normalize(skinnedNrm);
|
|
289
|
-
output.uv = uv;
|
|
290
|
-
output.worldPos = worldPos;
|
|
291
|
-
return output;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
@fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
|
|
295
|
-
let finalAlpha = material.alpha;
|
|
296
|
-
if (finalAlpha < 0.001) {
|
|
297
|
-
discard;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
let n = normalize(input.normal);
|
|
301
|
-
let textureColor = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
|
|
302
|
-
|
|
303
|
-
let viewDir = normalize(camera.viewPos - input.worldPos);
|
|
304
|
-
|
|
305
|
-
let albedo = textureColor * material.diffuseColor;
|
|
306
|
-
|
|
307
|
-
let minSpec = light.ambientColor.w;
|
|
308
|
-
let effectiveSpecular = max(material.specularColor, vec3f(minSpec));
|
|
309
|
-
let specPower = max(material.shininess, 1.0);
|
|
310
|
-
|
|
311
|
-
let l = -light.lights[0].direction.xyz;
|
|
312
|
-
let nDotL = max(dot(n, l), 0.0);
|
|
313
|
-
let intensity = light.lights[0].color.w;
|
|
314
|
-
let radiance = light.lights[0].color.xyz * intensity;
|
|
315
|
-
|
|
316
|
-
let lightAccum = light.ambientColor.xyz + radiance * nDotL;
|
|
317
|
-
|
|
318
|
-
let h = normalize(l + viewDir);
|
|
319
|
-
let nDotH = max(dot(n, h), 0.0);
|
|
320
|
-
let specFactor = pow(nDotH, specPower);
|
|
321
|
-
let specularAccum = effectiveSpecular * radiance * specFactor * nDotL;
|
|
322
|
-
|
|
323
|
-
let litColor = albedo * lightAccum;
|
|
324
|
-
|
|
325
|
-
let fresnel = 1.0 - abs(dot(n, viewDir));
|
|
326
|
-
let rimFactor = pow(fresnel, 4.0);
|
|
327
|
-
let rimLight = material.rimColor * material.rimIntensity * rimFactor;
|
|
328
|
-
|
|
329
|
-
let color = litColor + specularAccum + rimLight;
|
|
330
|
-
|
|
331
|
-
return vec4f(color, finalAlpha);
|
|
332
|
-
}
|
|
333
|
-
`,
|
|
421
|
+
label: "default model shader",
|
|
422
|
+
code: DEFAULT_SHADER_WGSL,
|
|
423
|
+
});
|
|
424
|
+
const faceShaderModule = this.device.createShaderModule({
|
|
425
|
+
label: "face NPR shader",
|
|
426
|
+
code: FACE_SHADER_WGSL,
|
|
427
|
+
});
|
|
428
|
+
const hairShaderModule = this.device.createShaderModule({
|
|
429
|
+
label: "hair NPR shader",
|
|
430
|
+
code: HAIR_SHADER_WGSL,
|
|
431
|
+
});
|
|
432
|
+
const clothSmoothShaderModule = this.device.createShaderModule({
|
|
433
|
+
label: "cloth smooth NPR shader",
|
|
434
|
+
code: CLOTH_SMOOTH_SHADER_WGSL,
|
|
435
|
+
});
|
|
436
|
+
const clothRoughShaderModule = this.device.createShaderModule({
|
|
437
|
+
label: "cloth rough NPR shader",
|
|
438
|
+
code: CLOTH_ROUGH_SHADER_WGSL,
|
|
439
|
+
});
|
|
440
|
+
const metalShaderModule = this.device.createShaderModule({
|
|
441
|
+
label: "metal NPR shader",
|
|
442
|
+
code: METAL_SHADER_WGSL,
|
|
443
|
+
});
|
|
444
|
+
const bodyShaderModule = this.device.createShaderModule({
|
|
445
|
+
label: "body NPR shader",
|
|
446
|
+
code: BODY_SHADER_WGSL,
|
|
334
447
|
});
|
|
335
|
-
|
|
448
|
+
const eyeShaderModule = this.device.createShaderModule({
|
|
449
|
+
label: "eye shader",
|
|
450
|
+
code: EYE_SHADER_WGSL,
|
|
451
|
+
});
|
|
452
|
+
const stockingsShaderModule = this.device.createShaderModule({
|
|
453
|
+
label: "stockings NPR shader",
|
|
454
|
+
code: STOCKINGS_SHADER_WGSL,
|
|
455
|
+
});
|
|
456
|
+
// group 0: per-frame (camera + light + sampler + shadow) — bound once per pass
|
|
336
457
|
this.mainPerFrameBindGroupLayout = this.device.createBindGroupLayout({
|
|
337
458
|
label: "main per-frame bind group layout",
|
|
338
459
|
entries: [
|
|
339
460
|
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
340
461
|
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
341
462
|
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
463
|
+
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
|
|
464
|
+
{ binding: 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" } },
|
|
465
|
+
{ binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
466
|
+
{ binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
467
|
+
{ binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
342
468
|
],
|
|
343
469
|
});
|
|
344
470
|
// group 1: per-instance (skinMats) — bound once per model
|
|
345
471
|
this.mainPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
|
|
346
472
|
label: "main per-instance bind group layout",
|
|
347
|
-
entries: [
|
|
348
|
-
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
349
|
-
],
|
|
473
|
+
entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }],
|
|
350
474
|
});
|
|
351
475
|
// group 2: per-material (texture + material uniforms) — bound per draw call
|
|
352
476
|
this.mainPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
|
|
@@ -358,17 +482,13 @@ export class Engine {
|
|
|
358
482
|
});
|
|
359
483
|
const mainPipelineLayout = this.device.createPipelineLayout({
|
|
360
484
|
label: "main pipeline layout",
|
|
361
|
-
bindGroupLayouts: [
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
layout: this.mainPerFrameBindGroupLayout,
|
|
366
|
-
entries: [
|
|
367
|
-
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
368
|
-
{ binding: 1, resource: { buffer: this.lightUniformBuffer } },
|
|
369
|
-
{ binding: 2, resource: this.materialSampler },
|
|
485
|
+
bindGroupLayouts: [
|
|
486
|
+
this.mainPerFrameBindGroupLayout,
|
|
487
|
+
this.mainPerInstanceBindGroupLayout,
|
|
488
|
+
this.mainPerMaterialBindGroupLayout,
|
|
370
489
|
],
|
|
371
490
|
});
|
|
491
|
+
// perFrameBindGroup is created after shadow resources below
|
|
372
492
|
this.modelPipeline = this.createRenderPipeline({
|
|
373
493
|
label: "model pipeline",
|
|
374
494
|
layout: mainPipelineLayout,
|
|
@@ -382,6 +502,110 @@ export class Engine {
|
|
|
382
502
|
depthCompare: "less-equal",
|
|
383
503
|
},
|
|
384
504
|
});
|
|
505
|
+
this.facePipeline = this.createRenderPipeline({
|
|
506
|
+
label: "face NPR pipeline",
|
|
507
|
+
layout: mainPipelineLayout,
|
|
508
|
+
shaderModule: faceShaderModule,
|
|
509
|
+
vertexBuffers: fullVertexBuffers,
|
|
510
|
+
fragmentTarget: standardBlend,
|
|
511
|
+
cullMode: "none",
|
|
512
|
+
depthStencil: {
|
|
513
|
+
format: "depth24plus-stencil8",
|
|
514
|
+
depthWriteEnabled: true,
|
|
515
|
+
depthCompare: "less-equal",
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
this.hairPipeline = this.createRenderPipeline({
|
|
519
|
+
label: "hair NPR pipeline",
|
|
520
|
+
layout: mainPipelineLayout,
|
|
521
|
+
shaderModule: hairShaderModule,
|
|
522
|
+
vertexBuffers: fullVertexBuffers,
|
|
523
|
+
fragmentTarget: standardBlend,
|
|
524
|
+
cullMode: "none",
|
|
525
|
+
depthStencil: {
|
|
526
|
+
format: "depth24plus-stencil8",
|
|
527
|
+
depthWriteEnabled: true,
|
|
528
|
+
depthCompare: "less-equal",
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
this.clothSmoothPipeline = this.createRenderPipeline({
|
|
532
|
+
label: "cloth smooth NPR pipeline",
|
|
533
|
+
layout: mainPipelineLayout,
|
|
534
|
+
shaderModule: clothSmoothShaderModule,
|
|
535
|
+
vertexBuffers: fullVertexBuffers,
|
|
536
|
+
fragmentTarget: standardBlend,
|
|
537
|
+
cullMode: "none",
|
|
538
|
+
depthStencil: {
|
|
539
|
+
format: "depth24plus-stencil8",
|
|
540
|
+
depthWriteEnabled: true,
|
|
541
|
+
depthCompare: "less-equal",
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
this.clothRoughPipeline = this.createRenderPipeline({
|
|
545
|
+
label: "cloth rough NPR pipeline",
|
|
546
|
+
layout: mainPipelineLayout,
|
|
547
|
+
shaderModule: clothRoughShaderModule,
|
|
548
|
+
vertexBuffers: fullVertexBuffers,
|
|
549
|
+
fragmentTarget: standardBlend,
|
|
550
|
+
cullMode: "none",
|
|
551
|
+
depthStencil: {
|
|
552
|
+
format: "depth24plus-stencil8",
|
|
553
|
+
depthWriteEnabled: true,
|
|
554
|
+
depthCompare: "less-equal",
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
this.metalPipeline = this.createRenderPipeline({
|
|
558
|
+
label: "metal NPR pipeline",
|
|
559
|
+
layout: mainPipelineLayout,
|
|
560
|
+
shaderModule: metalShaderModule,
|
|
561
|
+
vertexBuffers: fullVertexBuffers,
|
|
562
|
+
fragmentTarget: standardBlend,
|
|
563
|
+
cullMode: "none",
|
|
564
|
+
depthStencil: {
|
|
565
|
+
format: "depth24plus-stencil8",
|
|
566
|
+
depthWriteEnabled: true,
|
|
567
|
+
depthCompare: "less-equal",
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
this.bodyPipeline = this.createRenderPipeline({
|
|
571
|
+
label: "body NPR pipeline",
|
|
572
|
+
layout: mainPipelineLayout,
|
|
573
|
+
shaderModule: bodyShaderModule,
|
|
574
|
+
vertexBuffers: fullVertexBuffers,
|
|
575
|
+
fragmentTarget: standardBlend,
|
|
576
|
+
cullMode: "none",
|
|
577
|
+
depthStencil: {
|
|
578
|
+
format: "depth24plus-stencil8",
|
|
579
|
+
depthWriteEnabled: true,
|
|
580
|
+
depthCompare: "less-equal",
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
this.eyePipeline = this.createRenderPipeline({
|
|
584
|
+
label: "eye pipeline",
|
|
585
|
+
layout: mainPipelineLayout,
|
|
586
|
+
shaderModule: eyeShaderModule,
|
|
587
|
+
vertexBuffers: fullVertexBuffers,
|
|
588
|
+
fragmentTarget: standardBlend,
|
|
589
|
+
cullMode: "none",
|
|
590
|
+
depthStencil: {
|
|
591
|
+
format: "depth24plus-stencil8",
|
|
592
|
+
depthWriteEnabled: true,
|
|
593
|
+
depthCompare: "less-equal",
|
|
594
|
+
},
|
|
595
|
+
});
|
|
596
|
+
this.stockingsPipeline = this.createRenderPipeline({
|
|
597
|
+
label: "stockings NPR pipeline",
|
|
598
|
+
layout: mainPipelineLayout,
|
|
599
|
+
shaderModule: stockingsShaderModule,
|
|
600
|
+
vertexBuffers: fullVertexBuffers,
|
|
601
|
+
fragmentTarget: standardBlend,
|
|
602
|
+
cullMode: "none",
|
|
603
|
+
depthStencil: {
|
|
604
|
+
format: "depth24plus-stencil8",
|
|
605
|
+
depthWriteEnabled: true,
|
|
606
|
+
depthCompare: "less-equal",
|
|
607
|
+
},
|
|
608
|
+
});
|
|
385
609
|
this.shadowLightVPBuffer = this.device.createBuffer({
|
|
386
610
|
size: 64,
|
|
387
611
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
@@ -430,6 +654,32 @@ export class Engine {
|
|
|
430
654
|
magFilter: "linear",
|
|
431
655
|
minFilter: "linear",
|
|
432
656
|
});
|
|
657
|
+
this.shadowMapTexture = this.device.createTexture({
|
|
658
|
+
label: "shadow map",
|
|
659
|
+
size: [Engine.SHADOW_MAP_SIZE, Engine.SHADOW_MAP_SIZE],
|
|
660
|
+
format: "depth32float",
|
|
661
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
662
|
+
});
|
|
663
|
+
this.shadowMapDepthView = this.shadowMapTexture.createView();
|
|
664
|
+
// One-shot bake of Blender EEVEE's BRDF split-sum DFG LUT (bsdf_lut_frag.glsl).
|
|
665
|
+
this.bakeDfgLut();
|
|
666
|
+
// Upload static LTC GGX magnitude LUT for direct-specular energy compensation.
|
|
667
|
+
this.uploadLtcMagLut();
|
|
668
|
+
// Now that shadow resources exist, create the main per-frame bind group
|
|
669
|
+
this.perFrameBindGroup = this.device.createBindGroup({
|
|
670
|
+
label: "main per-frame bind group",
|
|
671
|
+
layout: this.mainPerFrameBindGroupLayout,
|
|
672
|
+
entries: [
|
|
673
|
+
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
674
|
+
{ binding: 1, resource: { buffer: this.lightUniformBuffer } },
|
|
675
|
+
{ binding: 2, resource: this.materialSampler },
|
|
676
|
+
{ binding: 3, resource: this.shadowMapDepthView },
|
|
677
|
+
{ binding: 4, resource: this.shadowComparisonSampler },
|
|
678
|
+
{ binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
|
|
679
|
+
{ binding: 9, resource: this.dfgLutView },
|
|
680
|
+
{ binding: 10, resource: this.ltcMagLutView },
|
|
681
|
+
],
|
|
682
|
+
});
|
|
433
683
|
this.groundShadowBindGroupLayout = this.device.createBindGroupLayout({
|
|
434
684
|
label: "ground shadow layout",
|
|
435
685
|
entries: [
|
|
@@ -558,14 +808,16 @@ export class Engine {
|
|
|
558
808
|
});
|
|
559
809
|
const outlinePipelineLayout = this.device.createPipelineLayout({
|
|
560
810
|
label: "outline pipeline layout",
|
|
561
|
-
bindGroupLayouts: [
|
|
811
|
+
bindGroupLayouts: [
|
|
812
|
+
this.outlinePerFrameBindGroupLayout,
|
|
813
|
+
this.mainPerInstanceBindGroupLayout,
|
|
814
|
+
this.outlinePerMaterialBindGroupLayout,
|
|
815
|
+
],
|
|
562
816
|
});
|
|
563
817
|
this.outlinePerFrameBindGroup = this.device.createBindGroup({
|
|
564
818
|
label: "outline per-frame bind group",
|
|
565
819
|
layout: this.outlinePerFrameBindGroupLayout,
|
|
566
|
-
entries: [
|
|
567
|
-
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
568
|
-
],
|
|
820
|
+
entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
|
|
569
821
|
});
|
|
570
822
|
const outlineShaderModule = this.device.createShaderModule({
|
|
571
823
|
label: "outline shaders",
|
|
@@ -621,12 +873,27 @@ export class Engine {
|
|
|
621
873
|
}
|
|
622
874
|
let worldPos = skinnedPos.xyz;
|
|
623
875
|
let worldNormal = normalize(skinnedNrm);
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
876
|
+
|
|
877
|
+
// Screen-space outline extrusion — MMD-style pixel-stable edge line.
|
|
878
|
+
// 1. Project position and normal-as-direction to clip space.
|
|
879
|
+
// 2. Normalize the 2D clip-space normal, aspect-compensated so "one pixel horizontally"
|
|
880
|
+
// matches "one pixel vertically" (otherwise wide viewports squash the outline in X).
|
|
881
|
+
// 3. Offset clip-space xy by (normal * edgeSize * edgeScale), then multiply by w
|
|
882
|
+
// so the perspective divide cancels out → offset stays constant in NDC regardless
|
|
883
|
+
// of depth, matching how MMD / babylon-mmd style outlines look identical when zooming.
|
|
884
|
+
// 4. edgeScale is in NDC-y units per PMX edgeSize. ≈ 0.006 gives ~3px at 1080p; it's
|
|
885
|
+
// tied to viewport HEIGHT so resizing the window keeps pixel thickness stable.
|
|
886
|
+
let viewProj = camera.projection * camera.view;
|
|
887
|
+
let clipPos = viewProj * vec4f(worldPos, 1.0);
|
|
888
|
+
let clipNormal = (viewProj * vec4f(worldNormal, 0.0)).xy;
|
|
889
|
+
// projection is column-major: proj[0][0] = 1/(aspect·tan(fov/2)), proj[1][1] = 1/tan(fov/2).
|
|
890
|
+
// Ratio proj[1][1]/proj[0][0] recovers the viewport aspect (width/height).
|
|
891
|
+
let aspect = camera.projection[1][1] / camera.projection[0][0];
|
|
892
|
+
let pixelDir = normalize(vec2f(clipNormal.x * aspect, clipNormal.y));
|
|
893
|
+
let ndcDir = vec2f(pixelDir.x / aspect, pixelDir.y);
|
|
894
|
+
let edgeScale = 0.0016;
|
|
895
|
+
let offset = ndcDir * material.edgeSize * edgeScale * clipPos.w;
|
|
896
|
+
output.position = vec4f(clipPos.xy + offset, clipPos.z, clipPos.w);
|
|
630
897
|
return output;
|
|
631
898
|
}
|
|
632
899
|
|
|
@@ -649,6 +916,277 @@ export class Engine {
|
|
|
649
916
|
depthCompare: "less-equal",
|
|
650
917
|
},
|
|
651
918
|
});
|
|
919
|
+
// ─── Bloom (EEVEE 3.6 pyramid): blit(Karis prefilter) → 13-tap downsamples → 9-tap tent upsamples ───
|
|
920
|
+
// Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl.
|
|
921
|
+
// Firefly suppression lives in the blit (Karis luminance-weighted 4-tap average). A single-pass
|
|
922
|
+
// Gaussian cannot reproduce this — hot pixels dominate and produce the sparkle halo.
|
|
923
|
+
this.bloomSampler = this.device.createSampler({
|
|
924
|
+
label: "bloom sampler",
|
|
925
|
+
magFilter: "linear",
|
|
926
|
+
minFilter: "linear",
|
|
927
|
+
addressModeU: "clamp-to-edge",
|
|
928
|
+
addressModeV: "clamp-to-edge",
|
|
929
|
+
});
|
|
930
|
+
this.bloomBlitUniformBuffer = this.device.createBuffer({
|
|
931
|
+
label: "bloom blit uniforms",
|
|
932
|
+
size: 16,
|
|
933
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
934
|
+
});
|
|
935
|
+
this.bloomUpsampleUniformBuffer = this.device.createBuffer({
|
|
936
|
+
label: "bloom upsample uniforms",
|
|
937
|
+
size: 16,
|
|
938
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
939
|
+
});
|
|
940
|
+
this.bloomBlitBindGroupLayout = this.device.createBindGroupLayout({
|
|
941
|
+
label: "bloom blit layout",
|
|
942
|
+
entries: [
|
|
943
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
|
|
944
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
945
|
+
],
|
|
946
|
+
});
|
|
947
|
+
this.bloomDownsampleBindGroupLayout = this.device.createBindGroupLayout({
|
|
948
|
+
label: "bloom downsample layout",
|
|
949
|
+
entries: [
|
|
950
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
951
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
952
|
+
],
|
|
953
|
+
});
|
|
954
|
+
this.bloomUpsampleBindGroupLayout = this.device.createBindGroupLayout({
|
|
955
|
+
label: "bloom upsample layout",
|
|
956
|
+
entries: [
|
|
957
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // coarser-mip accumulator
|
|
958
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // matching downsample mip (base add)
|
|
959
|
+
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
960
|
+
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
961
|
+
],
|
|
962
|
+
});
|
|
963
|
+
const bloomFullscreenVs = /* wgsl */ `
|
|
964
|
+
@vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
|
965
|
+
let x = f32((vi & 1u) << 2u) - 1.0;
|
|
966
|
+
let y = f32((vi & 2u) << 1u) - 1.0;
|
|
967
|
+
return vec4f(x, y, 0.0, 1.0);
|
|
968
|
+
}
|
|
969
|
+
`;
|
|
970
|
+
// Blit: full-res HDR → half-res. Karis 4-tap firefly average + EEVEE quadratic knee threshold + clamp.
|
|
971
|
+
const bloomBlitShader = this.device.createShaderModule({
|
|
972
|
+
label: "bloom blit (Karis prefilter)",
|
|
973
|
+
code: `${bloomFullscreenVs}
|
|
974
|
+
@group(0) @binding(0) var hdrTex: texture_2d<f32>;
|
|
975
|
+
@group(0) @binding(1) var<uniform> prefilter: vec4<f32>; // threshold, knee, clamp, _unused
|
|
976
|
+
|
|
977
|
+
fn luminance(c: vec3f) -> f32 {
|
|
978
|
+
return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));
|
|
979
|
+
}
|
|
980
|
+
fn fetch(c: vec2<i32>, clampV: f32) -> vec3f {
|
|
981
|
+
let d = vec2<i32>(textureDimensions(hdrTex));
|
|
982
|
+
let cc = clamp(c, vec2<i32>(0), d - vec2<i32>(1));
|
|
983
|
+
let s = textureLoad(hdrTex, cc, 0);
|
|
984
|
+
// Scene pass uses src-alpha blend with clear alpha 0 → premultiplied. Unpremultiply.
|
|
985
|
+
let rgb = max(s.rgb / max(s.a, 1e-6), vec3f(0.0));
|
|
986
|
+
// Blender: clamp each tap BEFORE Karis average (eevee_bloom: color = min(clampIntensity, color)).
|
|
987
|
+
return select(rgb, min(rgb, vec3f(clampV)), clampV > 0.0);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
|
|
991
|
+
let dst = vec2<i32>(p.xy - vec2f(0.5));
|
|
992
|
+
let base = dst * 2;
|
|
993
|
+
let clampV = prefilter.z;
|
|
994
|
+
let a = fetch(base + vec2<i32>(0, 0), clampV);
|
|
995
|
+
let b = fetch(base + vec2<i32>(1, 0), clampV);
|
|
996
|
+
let c = fetch(base + vec2<i32>(0, 1), clampV);
|
|
997
|
+
let d = fetch(base + vec2<i32>(1, 1), clampV);
|
|
998
|
+
// Karis partial average: weight each tap by 1/(1+luma) — suppresses fireflies.
|
|
999
|
+
let wa = 1.0 / (1.0 + luminance(a));
|
|
1000
|
+
let wb = 1.0 / (1.0 + luminance(b));
|
|
1001
|
+
let wc = 1.0 / (1.0 + luminance(c));
|
|
1002
|
+
let wd = 1.0 / (1.0 + luminance(d));
|
|
1003
|
+
let avg = (a * wa + b * wb + c * wc + d * wd) / max(wa + wb + wc + wd, 1e-6);
|
|
1004
|
+
// EEVEE quadratic threshold (brightness = max-channel, then soft-knee curve).
|
|
1005
|
+
let bright = max(avg.r, max(avg.g, avg.b));
|
|
1006
|
+
let soft = clamp(bright - prefilter.x + prefilter.y, 0.0, 2.0 * prefilter.y);
|
|
1007
|
+
let q = (soft * soft) / (4.0 * max(prefilter.y, 1e-4) + 1e-6);
|
|
1008
|
+
let contrib = max(q, bright - prefilter.x) / max(bright, 1e-4);
|
|
1009
|
+
return vec4f(max(avg * contrib, vec3f(0.0)), 1.0);
|
|
1010
|
+
}
|
|
1011
|
+
`,
|
|
1012
|
+
});
|
|
1013
|
+
// Downsample: Jimenez/COD 13-tap dual-box — 5 weighted 2×2 averages, rejects nyquist ringing.
|
|
1014
|
+
const bloomDownsampleShader = this.device.createShaderModule({
|
|
1015
|
+
label: "bloom downsample 13-tap",
|
|
1016
|
+
code: `${bloomFullscreenVs}
|
|
1017
|
+
@group(0) @binding(0) var srcTex: texture_2d<f32>;
|
|
1018
|
+
@group(0) @binding(1) var srcSamp: sampler;
|
|
1019
|
+
|
|
1020
|
+
fn samp(uv: vec2f, off: vec2f) -> vec3f {
|
|
1021
|
+
return textureSampleLevel(srcTex, srcSamp, uv + off, 0.0).rgb;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
|
|
1025
|
+
let srcDims = vec2f(textureDimensions(srcTex));
|
|
1026
|
+
let t = 1.0 / srcDims;
|
|
1027
|
+
// fragCoord.xy reports pixel centers (e.g. 0.5,0.5 for first pixel) — divide by dst dims directly.
|
|
1028
|
+
let dstDims = srcDims * 0.5;
|
|
1029
|
+
let uv = p.xy / max(dstDims, vec2f(1.0));
|
|
1030
|
+
let A = samp(uv, t * vec2f(-2.0, -2.0));
|
|
1031
|
+
let B = samp(uv, t * vec2f( 0.0, -2.0));
|
|
1032
|
+
let C = samp(uv, t * vec2f( 2.0, -2.0));
|
|
1033
|
+
let D = samp(uv, t * vec2f(-1.0, -1.0));
|
|
1034
|
+
let E = samp(uv, t * vec2f( 1.0, -1.0));
|
|
1035
|
+
let F = samp(uv, t * vec2f(-2.0, 0.0));
|
|
1036
|
+
let G = samp(uv, t * vec2f( 0.0, 0.0));
|
|
1037
|
+
let H = samp(uv, t * vec2f( 2.0, 0.0));
|
|
1038
|
+
let I = samp(uv, t * vec2f(-1.0, 1.0));
|
|
1039
|
+
let J = samp(uv, t * vec2f( 1.0, 1.0));
|
|
1040
|
+
let K = samp(uv, t * vec2f(-2.0, 2.0));
|
|
1041
|
+
let L = samp(uv, t * vec2f( 0.0, 2.0));
|
|
1042
|
+
let M = samp(uv, t * vec2f( 2.0, 2.0));
|
|
1043
|
+
var o = (D + E + I + J) * (0.5 / 4.0);
|
|
1044
|
+
o = o + (A + B + G + F) * (0.125 / 4.0);
|
|
1045
|
+
o = o + (B + C + H + G) * (0.125 / 4.0);
|
|
1046
|
+
o = o + (F + G + L + K) * (0.125 / 4.0);
|
|
1047
|
+
o = o + (G + H + M + L) * (0.125 / 4.0);
|
|
1048
|
+
return vec4f(o, 1.0);
|
|
1049
|
+
}
|
|
1050
|
+
`,
|
|
1051
|
+
});
|
|
1052
|
+
// Upsample: 9-tap tent, progressively added to matching downsample mip. Blender radius = sample scale.
|
|
1053
|
+
const bloomUpsampleShader = this.device.createShaderModule({
|
|
1054
|
+
label: "bloom upsample 9-tap tent",
|
|
1055
|
+
code: `${bloomFullscreenVs}
|
|
1056
|
+
@group(0) @binding(0) var srcTex: texture_2d<f32>; // coarser accumulator
|
|
1057
|
+
@group(0) @binding(1) var baseTex: texture_2d<f32>; // matching downsample mip
|
|
1058
|
+
@group(0) @binding(2) var srcSamp: sampler;
|
|
1059
|
+
@group(0) @binding(3) var<uniform> upU: vec4<f32>; // sampleScale, _, _, _
|
|
1060
|
+
|
|
1061
|
+
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
|
|
1062
|
+
let srcDims = vec2f(textureDimensions(srcTex));
|
|
1063
|
+
let baseDims = vec2f(textureDimensions(baseTex));
|
|
1064
|
+
let uv = p.xy / max(baseDims, vec2f(1.0));
|
|
1065
|
+
let t = upU.x / srcDims;
|
|
1066
|
+
var o = textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, -1.0), 0.0).rgb * 1.0;
|
|
1067
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, -1.0), 0.0).rgb * 2.0;
|
|
1068
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, -1.0), 0.0).rgb * 1.0;
|
|
1069
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 0.0), 0.0).rgb * 2.0;
|
|
1070
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 0.0), 0.0).rgb * 4.0;
|
|
1071
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 0.0), 0.0).rgb * 2.0;
|
|
1072
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 1.0), 0.0).rgb * 1.0;
|
|
1073
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 1.0), 0.0).rgb * 2.0;
|
|
1074
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 1.0), 0.0).rgb * 1.0;
|
|
1075
|
+
o = o * (1.0 / 16.0);
|
|
1076
|
+
let base = textureSampleLevel(baseTex, srcSamp, uv, 0.0).rgb;
|
|
1077
|
+
return vec4f(o + base, 1.0);
|
|
1078
|
+
}
|
|
1079
|
+
`,
|
|
1080
|
+
});
|
|
1081
|
+
const bloomBlitLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomBlitBindGroupLayout] });
|
|
1082
|
+
const bloomDownLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomDownsampleBindGroupLayout] });
|
|
1083
|
+
const bloomUpLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomUpsampleBindGroupLayout] });
|
|
1084
|
+
this.bloomBlitPipeline = this.device.createRenderPipeline({
|
|
1085
|
+
label: "bloom blit pipeline",
|
|
1086
|
+
layout: bloomBlitLayout,
|
|
1087
|
+
vertex: { module: bloomBlitShader, entryPoint: "vs" },
|
|
1088
|
+
fragment: { module: bloomBlitShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
|
|
1089
|
+
primitive: { topology: "triangle-list" },
|
|
1090
|
+
});
|
|
1091
|
+
this.bloomDownsamplePipeline = this.device.createRenderPipeline({
|
|
1092
|
+
label: "bloom downsample pipeline",
|
|
1093
|
+
layout: bloomDownLayout,
|
|
1094
|
+
vertex: { module: bloomDownsampleShader, entryPoint: "vs" },
|
|
1095
|
+
fragment: { module: bloomDownsampleShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
|
|
1096
|
+
primitive: { topology: "triangle-list" },
|
|
1097
|
+
});
|
|
1098
|
+
this.bloomUpsamplePipeline = this.device.createRenderPipeline({
|
|
1099
|
+
label: "bloom upsample pipeline",
|
|
1100
|
+
layout: bloomUpLayout,
|
|
1101
|
+
vertex: { module: bloomUpsampleShader, entryPoint: "vs" },
|
|
1102
|
+
fragment: { module: bloomUpsampleShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
|
|
1103
|
+
primitive: { topology: "triangle-list" },
|
|
1104
|
+
});
|
|
1105
|
+
// ─── Composite: HDR + bloom → Filmic → swapchain (premultiplied) ───
|
|
1106
|
+
// Bloom color/intensity applied HERE (pyramid is pure energy; tint belongs to the combine step,
|
|
1107
|
+
// mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
|
|
1108
|
+
this.compositeUniformBuffer = this.device.createBuffer({
|
|
1109
|
+
label: "composite view uniforms",
|
|
1110
|
+
size: 32,
|
|
1111
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1112
|
+
});
|
|
1113
|
+
this.compositeBindGroupLayout = this.device.createBindGroupLayout({
|
|
1114
|
+
label: "composite bind group layout",
|
|
1115
|
+
entries: [
|
|
1116
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
|
|
1117
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
1118
|
+
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
1119
|
+
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
1120
|
+
],
|
|
1121
|
+
});
|
|
1122
|
+
const compositeShader = this.device.createShaderModule({
|
|
1123
|
+
label: "composite shader",
|
|
1124
|
+
code: /* wgsl */ `
|
|
1125
|
+
@group(0) @binding(0) var hdrTex: texture_2d<f32>;
|
|
1126
|
+
@group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)
|
|
1127
|
+
@group(0) @binding(2) var bloomSamp: sampler;
|
|
1128
|
+
@group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 2>;
|
|
1129
|
+
// viewU[0] = (exposure, gamma, _, _); viewU[1] = (tint.rgb, intensity)
|
|
1130
|
+
|
|
1131
|
+
fn filmic(x: f32) -> f32 {
|
|
1132
|
+
var lut = array<f32, 14>(
|
|
1133
|
+
0.0067, 0.0141, 0.0272, 0.0499, 0.0885, 0.1512, 0.2462,
|
|
1134
|
+
0.3753, 0.5273, 0.6776, 0.8031, 0.8929, 0.9495, 0.9814
|
|
1135
|
+
);
|
|
1136
|
+
let t = clamp(log2(max(x, 1e-10)) + 10.0, 0.0, 13.0);
|
|
1137
|
+
let i = u32(t);
|
|
1138
|
+
let j = min(i + 1u, 13u);
|
|
1139
|
+
return mix(lut[i], lut[j], t - f32(i));
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
@vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
|
1143
|
+
let x = f32((vi & 1u) << 2u) - 1.0;
|
|
1144
|
+
let y = f32((vi & 2u) << 1u) - 1.0;
|
|
1145
|
+
return vec4f(x, y, 0.0, 1.0);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
@fragment fn fs(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f {
|
|
1149
|
+
let hdr = textureLoad(hdrTex, vec2<i32>(fragCoord.xy), 0);
|
|
1150
|
+
let a = max(hdr.a, 1e-6);
|
|
1151
|
+
let straight = hdr.rgb / a;
|
|
1152
|
+
let fullSz = vec2f(textureDimensions(hdrTex));
|
|
1153
|
+
let bloomSz = vec2f(textureDimensions(bloomTex));
|
|
1154
|
+
// Bloom is at half-res (pyramid mip 0). Sampler interpolates back to full-res UVs.
|
|
1155
|
+
let bloomUv = (fragCoord.xy + vec2f(0.5)) / max(fullSz, vec2f(1.0));
|
|
1156
|
+
let tint = viewU[1].xyz;
|
|
1157
|
+
let intensity = viewU[1].w;
|
|
1158
|
+
let bloom = textureSampleLevel(bloomTex, bloomSamp, bloomUv, 0.0).rgb * tint * intensity;
|
|
1159
|
+
let combined = straight + bloom;
|
|
1160
|
+
let exposed = combined * exp2(viewU[0].x);
|
|
1161
|
+
let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));
|
|
1162
|
+
let g = max(viewU[0].y, 1e-4);
|
|
1163
|
+
let disp = pow(max(tm, vec3f(0.0)), vec3f(1.0 / g));
|
|
1164
|
+
return vec4f(disp * hdr.a, hdr.a);
|
|
1165
|
+
}
|
|
1166
|
+
`,
|
|
1167
|
+
});
|
|
1168
|
+
this.compositePipeline = this.device.createRenderPipeline({
|
|
1169
|
+
label: "composite pipeline",
|
|
1170
|
+
layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.compositeBindGroupLayout] }),
|
|
1171
|
+
vertex: { module: compositeShader, entryPoint: "vs" },
|
|
1172
|
+
fragment: {
|
|
1173
|
+
module: compositeShader,
|
|
1174
|
+
entryPoint: "fs",
|
|
1175
|
+
targets: [{ format: this.presentationFormat }],
|
|
1176
|
+
},
|
|
1177
|
+
primitive: { topology: "triangle-list" },
|
|
1178
|
+
});
|
|
1179
|
+
this.bloomPassDescriptor = {
|
|
1180
|
+
label: "bloom pass",
|
|
1181
|
+
colorAttachments: [
|
|
1182
|
+
{
|
|
1183
|
+
view: undefined,
|
|
1184
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
1185
|
+
loadOp: "clear",
|
|
1186
|
+
storeOp: "store",
|
|
1187
|
+
},
|
|
1188
|
+
],
|
|
1189
|
+
};
|
|
652
1190
|
// GPU picking: encode (modelIndex, materialIndex) as color
|
|
653
1191
|
const pickShaderModule = this.device.createShaderModule({
|
|
654
1192
|
label: "pick shader",
|
|
@@ -693,32 +1231,28 @@ export class Engine {
|
|
|
693
1231
|
});
|
|
694
1232
|
this.pickPerFrameBindGroupLayout = this.device.createBindGroupLayout({
|
|
695
1233
|
label: "pick per-frame layout",
|
|
696
|
-
entries: [
|
|
697
|
-
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
|
|
698
|
-
],
|
|
1234
|
+
entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } }],
|
|
699
1235
|
});
|
|
700
1236
|
this.pickPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
|
|
701
1237
|
label: "pick per-instance layout",
|
|
702
|
-
entries: [
|
|
703
|
-
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
704
|
-
],
|
|
1238
|
+
entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }],
|
|
705
1239
|
});
|
|
706
1240
|
this.pickPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
|
|
707
1241
|
label: "pick per-material layout",
|
|
708
|
-
entries: [
|
|
709
|
-
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
710
|
-
],
|
|
1242
|
+
entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }],
|
|
711
1243
|
});
|
|
712
1244
|
const pickPipelineLayout = this.device.createPipelineLayout({
|
|
713
1245
|
label: "pick pipeline layout",
|
|
714
|
-
bindGroupLayouts: [
|
|
1246
|
+
bindGroupLayouts: [
|
|
1247
|
+
this.pickPerFrameBindGroupLayout,
|
|
1248
|
+
this.pickPerInstanceBindGroupLayout,
|
|
1249
|
+
this.pickPerMaterialBindGroupLayout,
|
|
1250
|
+
],
|
|
715
1251
|
});
|
|
716
1252
|
this.pickPerFrameBindGroup = this.device.createBindGroup({
|
|
717
1253
|
label: "pick per-frame bind group",
|
|
718
1254
|
layout: this.pickPerFrameBindGroupLayout,
|
|
719
|
-
entries: [
|
|
720
|
-
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
721
|
-
],
|
|
1255
|
+
entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
|
|
722
1256
|
});
|
|
723
1257
|
this.pickPipeline = this.device.createRenderPipeline({
|
|
724
1258
|
label: "pick pipeline",
|
|
@@ -762,12 +1296,47 @@ export class Engine {
|
|
|
762
1296
|
this.canvas.width = width;
|
|
763
1297
|
this.canvas.height = height;
|
|
764
1298
|
this.multisampleTexture = this.device.createTexture({
|
|
765
|
-
label: "multisample render target",
|
|
1299
|
+
label: "multisample HDR render target",
|
|
766
1300
|
size: [width, height],
|
|
767
1301
|
sampleCount: Engine.MULTISAMPLE_COUNT,
|
|
768
|
-
format:
|
|
1302
|
+
format: Engine.HDR_FORMAT,
|
|
769
1303
|
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
|
770
1304
|
});
|
|
1305
|
+
this.hdrResolveTexture = this.device.createTexture({
|
|
1306
|
+
label: "HDR resolve target",
|
|
1307
|
+
size: [width, height],
|
|
1308
|
+
format: Engine.HDR_FORMAT,
|
|
1309
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1310
|
+
});
|
|
1311
|
+
// Bloom pyramid: mip 0 is half-res, each subsequent mip halves again.
|
|
1312
|
+
// Mip count chosen so the coarsest mip is ≥4 px on the short side, capped at BLOOM_MAX_LEVELS.
|
|
1313
|
+
const bw = Math.max(1, Math.floor(width / 2));
|
|
1314
|
+
const bh = Math.max(1, Math.floor(height / 2));
|
|
1315
|
+
const shortSide = Math.max(1, Math.min(bw, bh));
|
|
1316
|
+
this.bloomMipCount = Math.max(1, Math.min(Engine.BLOOM_MAX_LEVELS, Math.floor(Math.log2(shortSide)) - 1));
|
|
1317
|
+
this.bloomDownTexture = this.device.createTexture({
|
|
1318
|
+
label: "bloom down pyramid",
|
|
1319
|
+
size: [bw, bh],
|
|
1320
|
+
mipLevelCount: this.bloomMipCount,
|
|
1321
|
+
format: Engine.HDR_FORMAT,
|
|
1322
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1323
|
+
});
|
|
1324
|
+
this.bloomUpTexture = this.device.createTexture({
|
|
1325
|
+
label: "bloom up pyramid",
|
|
1326
|
+
size: [bw, bh],
|
|
1327
|
+
mipLevelCount: Math.max(1, this.bloomMipCount - 1),
|
|
1328
|
+
format: Engine.HDR_FORMAT,
|
|
1329
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1330
|
+
});
|
|
1331
|
+
this.bloomDownMipViews = [];
|
|
1332
|
+
for (let i = 0; i < this.bloomMipCount; i++) {
|
|
1333
|
+
this.bloomDownMipViews.push(this.bloomDownTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }));
|
|
1334
|
+
}
|
|
1335
|
+
this.bloomUpMipViews = [];
|
|
1336
|
+
const upLevels = Math.max(1, this.bloomMipCount - 1);
|
|
1337
|
+
for (let i = 0; i < upLevels; i++) {
|
|
1338
|
+
this.bloomUpMipViews.push(this.bloomUpTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }));
|
|
1339
|
+
}
|
|
771
1340
|
this.depthTexture = this.device.createTexture({
|
|
772
1341
|
label: "depth texture",
|
|
773
1342
|
size: [width, height],
|
|
@@ -778,7 +1347,7 @@ export class Engine {
|
|
|
778
1347
|
const depthTextureView = this.depthTexture.createView();
|
|
779
1348
|
const colorAttachment = {
|
|
780
1349
|
view: this.multisampleTexture.createView(),
|
|
781
|
-
resolveTarget: this.
|
|
1350
|
+
resolveTarget: this.hdrResolveTexture.createView(),
|
|
782
1351
|
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
783
1352
|
loadOp: "clear",
|
|
784
1353
|
storeOp: "store",
|
|
@@ -796,6 +1365,72 @@ export class Engine {
|
|
|
796
1365
|
stencilStoreOp: "discard",
|
|
797
1366
|
},
|
|
798
1367
|
};
|
|
1368
|
+
// Composite pass descriptor (color attachment view patched per-frame to current swapchain).
|
|
1369
|
+
this.compositePassDescriptor = {
|
|
1370
|
+
label: "composite pass",
|
|
1371
|
+
colorAttachments: [
|
|
1372
|
+
{
|
|
1373
|
+
view: undefined,
|
|
1374
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
1375
|
+
loadOp: "clear",
|
|
1376
|
+
storeOp: "store",
|
|
1377
|
+
},
|
|
1378
|
+
],
|
|
1379
|
+
};
|
|
1380
|
+
this.writeBloomUniforms();
|
|
1381
|
+
if (this.compositeBindGroupLayout && this.bloomBlitBindGroupLayout) {
|
|
1382
|
+
// Blit: reads HDR resolve texture (full-res), writes bloomDown mip 0.
|
|
1383
|
+
this.bloomBlitBindGroup = this.device.createBindGroup({
|
|
1384
|
+
label: "bloom blit bind group",
|
|
1385
|
+
layout: this.bloomBlitBindGroupLayout,
|
|
1386
|
+
entries: [
|
|
1387
|
+
{ binding: 0, resource: this.hdrResolveTexture.createView() },
|
|
1388
|
+
{ binding: 1, resource: { buffer: this.bloomBlitUniformBuffer } },
|
|
1389
|
+
],
|
|
1390
|
+
});
|
|
1391
|
+
// Downsample[i] reads bloomDown mip (i-1), writes bloomDown mip i. i ∈ [1..N-1].
|
|
1392
|
+
this.bloomDownsampleBindGroups = [];
|
|
1393
|
+
for (let i = 1; i < this.bloomMipCount; i++) {
|
|
1394
|
+
this.bloomDownsampleBindGroups.push(this.device.createBindGroup({
|
|
1395
|
+
label: `bloom downsample ${i}`,
|
|
1396
|
+
layout: this.bloomDownsampleBindGroupLayout,
|
|
1397
|
+
entries: [
|
|
1398
|
+
{ binding: 0, resource: this.bloomDownMipViews[i - 1] },
|
|
1399
|
+
{ binding: 1, resource: this.bloomSampler },
|
|
1400
|
+
],
|
|
1401
|
+
}));
|
|
1402
|
+
}
|
|
1403
|
+
// Upsample[i] writes bloomUp mip i. Coarsest step reads bloomDown[N-1] (no prior up yet);
|
|
1404
|
+
// subsequent steps read bloomUp[i+1]. Both read bloomDown[i] as the base (additive combine).
|
|
1405
|
+
this.bloomUpsampleBindGroups = [];
|
|
1406
|
+
const topIdx = this.bloomMipCount - 2;
|
|
1407
|
+
for (let i = topIdx; i >= 0; i--) {
|
|
1408
|
+
const srcView = i === topIdx ? this.bloomDownMipViews[this.bloomMipCount - 1] : this.bloomUpMipViews[i + 1];
|
|
1409
|
+
this.bloomUpsampleBindGroups.push(this.device.createBindGroup({
|
|
1410
|
+
label: `bloom upsample ${i}`,
|
|
1411
|
+
layout: this.bloomUpsampleBindGroupLayout,
|
|
1412
|
+
entries: [
|
|
1413
|
+
{ binding: 0, resource: srcView },
|
|
1414
|
+
{ binding: 1, resource: this.bloomDownMipViews[i] },
|
|
1415
|
+
{ binding: 2, resource: this.bloomSampler },
|
|
1416
|
+
{ binding: 3, resource: { buffer: this.bloomUpsampleUniformBuffer } },
|
|
1417
|
+
],
|
|
1418
|
+
}));
|
|
1419
|
+
}
|
|
1420
|
+
// Composite reads bloomUp mip 0 (full pyramid collapsed); fallback to bloomDown mip 0 if no upsample level.
|
|
1421
|
+
const compositeBloomView = this.bloomMipCount > 1 ? this.bloomUpMipViews[0] : this.bloomDownMipViews[0];
|
|
1422
|
+
this.compositeBindGroup = this.device.createBindGroup({
|
|
1423
|
+
label: "composite bind group",
|
|
1424
|
+
layout: this.compositeBindGroupLayout,
|
|
1425
|
+
entries: [
|
|
1426
|
+
{ binding: 0, resource: this.hdrResolveTexture.createView() },
|
|
1427
|
+
{ binding: 1, resource: compositeBloomView },
|
|
1428
|
+
{ binding: 2, resource: this.bloomSampler },
|
|
1429
|
+
{ binding: 3, resource: { buffer: this.compositeUniformBuffer } },
|
|
1430
|
+
],
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
this.writeCompositeViewUniforms();
|
|
799
1434
|
this.camera.aspect = width / height;
|
|
800
1435
|
if (this.onRaycast) {
|
|
801
1436
|
this.pickTexture = this.device.createTexture({
|
|
@@ -820,7 +1455,7 @@ export class Engine {
|
|
|
820
1455
|
size: 40 * 4,
|
|
821
1456
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
822
1457
|
});
|
|
823
|
-
this.camera = new Camera(Math.PI, Math.PI / 2.5, this.
|
|
1458
|
+
this.camera = new Camera(Math.PI, Math.PI / 2.5, this.cameraConfig.distance, this.cameraConfig.target, this.cameraConfig.fov);
|
|
824
1459
|
this.camera.aspect = this.canvas.width / this.canvas.height;
|
|
825
1460
|
this.camera.attachControl(this.canvas);
|
|
826
1461
|
}
|
|
@@ -854,58 +1489,100 @@ export class Engine {
|
|
|
854
1489
|
this.cameraTargetOffset.y = offset?.y ?? 0;
|
|
855
1490
|
this.cameraTargetOffset.z = offset?.z ?? 0;
|
|
856
1491
|
}
|
|
857
|
-
getCameraDistance() {
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
1492
|
+
getCameraDistance() {
|
|
1493
|
+
return this.camera.radius;
|
|
1494
|
+
}
|
|
1495
|
+
setCameraDistance(d) {
|
|
1496
|
+
this.camera.radius = d;
|
|
1497
|
+
}
|
|
1498
|
+
getCameraAlpha() {
|
|
1499
|
+
return this.camera.alpha;
|
|
1500
|
+
}
|
|
1501
|
+
setCameraAlpha(a) {
|
|
1502
|
+
this.camera.alpha = a;
|
|
1503
|
+
}
|
|
1504
|
+
getCameraBeta() {
|
|
1505
|
+
return this.camera.beta;
|
|
1506
|
+
}
|
|
1507
|
+
setCameraBeta(b) {
|
|
1508
|
+
this.camera.beta = b;
|
|
1509
|
+
}
|
|
863
1510
|
// Step 5: Create lighting buffers
|
|
864
1511
|
setupLighting() {
|
|
865
1512
|
this.lightUniformBuffer = this.device.createBuffer({
|
|
866
1513
|
label: "light uniforms",
|
|
867
|
-
size: 64 * 4, //
|
|
1514
|
+
size: 64 * 4, // ambientColor vec4f (4) + 4 lights * 2 vec4f each (32) = 36 f32 padded to 64
|
|
868
1515
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
869
1516
|
});
|
|
870
|
-
// Initialize light buffer to zeros
|
|
871
1517
|
this.lightData.fill(0);
|
|
872
1518
|
this.lightCount = 0;
|
|
873
|
-
this.
|
|
874
|
-
this.
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
1519
|
+
this.writeWorld();
|
|
1520
|
+
this.writeSun(0);
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Write world ambient. For a uniform-radiance world, hemispherical irradiance
|
|
1524
|
+
* is E = π·L and a Lambertian BRDF reflects (albedo/π)·E = albedo·L, so the
|
|
1525
|
+
* shader's ambient uniform is just `world.color × world.strength` — no /π.
|
|
1526
|
+
*/
|
|
1527
|
+
writeWorld() {
|
|
1528
|
+
const s = this.world.strength;
|
|
1529
|
+
this.lightData[0] = this.world.color.x * s;
|
|
1530
|
+
this.lightData[1] = this.world.color.y * s;
|
|
1531
|
+
this.lightData[2] = this.world.color.z * s;
|
|
1532
|
+
this.lightData[3] = 0;
|
|
882
1533
|
this.updateLightBuffer();
|
|
883
1534
|
}
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
const
|
|
889
|
-
|
|
890
|
-
this.lightData[
|
|
891
|
-
this.lightData[
|
|
892
|
-
this.lightData[
|
|
893
|
-
this.lightData[
|
|
894
|
-
this.lightData[
|
|
895
|
-
this.lightData[
|
|
896
|
-
this.lightData[
|
|
897
|
-
this.
|
|
1535
|
+
/** Write sun lamp into light slot `index` (0..3). Layout mirrors the WGSL struct. */
|
|
1536
|
+
writeSun(index) {
|
|
1537
|
+
if (index < 0 || index >= 4)
|
|
1538
|
+
return;
|
|
1539
|
+
const normalized = this.sun.direction.normalize();
|
|
1540
|
+
const base = 4 + index * 8; // 8 floats per light (direction vec4, color vec4)
|
|
1541
|
+
this.lightData[base] = normalized.x;
|
|
1542
|
+
this.lightData[base + 1] = normalized.y;
|
|
1543
|
+
this.lightData[base + 2] = normalized.z;
|
|
1544
|
+
this.lightData[base + 3] = 0;
|
|
1545
|
+
this.lightData[base + 4] = this.sun.color.x;
|
|
1546
|
+
this.lightData[base + 5] = this.sun.color.y;
|
|
1547
|
+
this.lightData[base + 6] = this.sun.color.z;
|
|
1548
|
+
this.lightData[base + 7] = this.sun.strength;
|
|
1549
|
+
if (index >= this.lightCount)
|
|
1550
|
+
this.lightCount = index + 1;
|
|
898
1551
|
this.updateLightBuffer();
|
|
899
|
-
|
|
1552
|
+
}
|
|
1553
|
+
/** Update the world environment (Blender: World Background). Ambient recomputes immediately. */
|
|
1554
|
+
setWorld(options) {
|
|
1555
|
+
if (options.color)
|
|
1556
|
+
this.world.color = options.color;
|
|
1557
|
+
if (options.strength !== undefined)
|
|
1558
|
+
this.world.strength = options.strength;
|
|
1559
|
+
this.writeWorld();
|
|
1560
|
+
}
|
|
1561
|
+
/** Update the sun lamp (Blender: Light > Sun). Direction change marks shadow VP dirty. */
|
|
1562
|
+
setSun(options) {
|
|
1563
|
+
if (options.color)
|
|
1564
|
+
this.sun.color = options.color;
|
|
1565
|
+
if (options.strength !== undefined)
|
|
1566
|
+
this.sun.strength = options.strength;
|
|
1567
|
+
if (options.direction) {
|
|
1568
|
+
this.sun.direction = options.direction;
|
|
1569
|
+
this.shadowLightVPDirty = true;
|
|
1570
|
+
}
|
|
1571
|
+
this.writeSun(0);
|
|
1572
|
+
}
|
|
1573
|
+
getWorld() {
|
|
1574
|
+
return this.world;
|
|
1575
|
+
}
|
|
1576
|
+
getSun() {
|
|
1577
|
+
return this.sun;
|
|
900
1578
|
}
|
|
901
1579
|
addGround(options) {
|
|
902
1580
|
const opts = {
|
|
903
1581
|
width: 160,
|
|
904
1582
|
height: 160,
|
|
905
|
-
diffuseColor: new Vec3(0.
|
|
1583
|
+
diffuseColor: new Vec3(0.9, 0.1, 1.0),
|
|
906
1584
|
fadeStart: 10.0,
|
|
907
1585
|
fadeEnd: 80.0,
|
|
908
|
-
shadowMapSize: 4096,
|
|
909
1586
|
shadowStrength: 1.0,
|
|
910
1587
|
gridSpacing: 4.2,
|
|
911
1588
|
gridLineWidth: 0.012,
|
|
@@ -923,6 +1600,7 @@ export class Engine {
|
|
|
923
1600
|
firstIndex: 0,
|
|
924
1601
|
bindGroup: this.groundShadowBindGroup,
|
|
925
1602
|
materialName: "Ground",
|
|
1603
|
+
preset: "cloth_rough",
|
|
926
1604
|
};
|
|
927
1605
|
}
|
|
928
1606
|
updateLightBuffer() {
|
|
@@ -1039,6 +1717,15 @@ export class Engine {
|
|
|
1039
1717
|
}
|
|
1040
1718
|
}
|
|
1041
1719
|
}
|
|
1720
|
+
setMaterialPresets(modelName, presets) {
|
|
1721
|
+
const inst = this.modelInstances.get(modelName);
|
|
1722
|
+
if (!inst)
|
|
1723
|
+
return;
|
|
1724
|
+
inst.materialPresets = presets;
|
|
1725
|
+
for (const dc of inst.drawCalls) {
|
|
1726
|
+
dc.preset = resolvePreset(dc.materialName, presets);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1042
1729
|
setMaterialVisible(modelName, materialName, visible) {
|
|
1043
1730
|
const inst = this.modelInstances.get(modelName);
|
|
1044
1731
|
if (!inst)
|
|
@@ -1147,24 +1834,14 @@ export class Engine {
|
|
|
1147
1834
|
const mainPerInstanceBindGroup = this.device.createBindGroup({
|
|
1148
1835
|
label: `${name}: main per-instance bind group`,
|
|
1149
1836
|
layout: this.mainPerInstanceBindGroupLayout,
|
|
1150
|
-
entries: [
|
|
1151
|
-
{ binding: 0, resource: { buffer: skinMatrixBuffer } },
|
|
1152
|
-
],
|
|
1837
|
+
entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
|
|
1153
1838
|
});
|
|
1154
1839
|
const pickPerInstanceBindGroup = this.device.createBindGroup({
|
|
1155
1840
|
label: `${name}: pick per-instance bind group`,
|
|
1156
1841
|
layout: this.pickPerInstanceBindGroupLayout,
|
|
1157
|
-
entries: [
|
|
1158
|
-
{ binding: 0, resource: { buffer: skinMatrixBuffer } },
|
|
1159
|
-
],
|
|
1842
|
+
entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
|
|
1160
1843
|
});
|
|
1161
|
-
const gpuBuffers = [
|
|
1162
|
-
vertexBuffer,
|
|
1163
|
-
indexBuffer,
|
|
1164
|
-
jointsBuffer,
|
|
1165
|
-
weightsBuffer,
|
|
1166
|
-
skinMatrixBuffer,
|
|
1167
|
-
];
|
|
1844
|
+
const gpuBuffers = [vertexBuffer, indexBuffer, jointsBuffer, weightsBuffer, skinMatrixBuffer];
|
|
1168
1845
|
const inst = {
|
|
1169
1846
|
name,
|
|
1170
1847
|
model,
|
|
@@ -1184,6 +1861,7 @@ export class Engine {
|
|
|
1184
1861
|
pickPerInstanceBindGroup,
|
|
1185
1862
|
pickDrawCalls: [],
|
|
1186
1863
|
hiddenMaterials: new Set(),
|
|
1864
|
+
materialPresets: undefined,
|
|
1187
1865
|
physics,
|
|
1188
1866
|
vertexBufferNeedsUpdate: false,
|
|
1189
1867
|
};
|
|
@@ -1255,15 +1933,8 @@ export class Engine {
|
|
|
1255
1933
|
this.device.queue.writeBuffer(this.groundIndexBuffer, 0, indices);
|
|
1256
1934
|
}
|
|
1257
1935
|
createShadowGroundResources(opts) {
|
|
1258
|
-
const {
|
|
1259
|
-
|
|
1260
|
-
label: "shadow map",
|
|
1261
|
-
size: [shadowMapSize, shadowMapSize],
|
|
1262
|
-
format: "depth32float",
|
|
1263
|
-
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1264
|
-
});
|
|
1265
|
-
this.shadowMapDepthView = this.shadowMapTexture.createView();
|
|
1266
|
-
// Layout: diffuseColor(3f) fadeStart(1f) | fadeEnd(1f) shadowStrength(1f) pcfTexel(1f) gridSpacing(1f) | gridLineWidth(1f) gridOpacity(1f) noiseStrength(1f) _pad(1f) | gridColor(3f) _pad2(1f)
|
|
1936
|
+
const { diffuseColor, fadeStart, fadeEnd, shadowStrength, gridSpacing, gridLineWidth, gridLineOpacity, gridLineColor, noiseStrength, } = opts;
|
|
1937
|
+
// Shadow map is already created in setupPipelines()
|
|
1267
1938
|
const gb = new Float32Array(16);
|
|
1268
1939
|
gb[0] = diffuseColor.x;
|
|
1269
1940
|
gb[1] = diffuseColor.y;
|
|
@@ -1271,7 +1942,7 @@ export class Engine {
|
|
|
1271
1942
|
gb[3] = fadeStart;
|
|
1272
1943
|
gb[4] = fadeEnd;
|
|
1273
1944
|
gb[5] = shadowStrength;
|
|
1274
|
-
gb[6] = 1 /
|
|
1945
|
+
gb[6] = 1 / Engine.SHADOW_MAP_SIZE;
|
|
1275
1946
|
gb[7] = gridSpacing;
|
|
1276
1947
|
gb[8] = gridLineWidth;
|
|
1277
1948
|
gb[9] = gridLineOpacity;
|
|
@@ -1281,7 +1952,10 @@ export class Engine {
|
|
|
1281
1952
|
gb[13] = gridLineColor.y;
|
|
1282
1953
|
gb[14] = gridLineColor.z;
|
|
1283
1954
|
gb[15] = 0;
|
|
1284
|
-
this.groundShadowMaterialBuffer = this.device.createBuffer({
|
|
1955
|
+
this.groundShadowMaterialBuffer = this.device.createBuffer({
|
|
1956
|
+
size: gb.byteLength,
|
|
1957
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1958
|
+
});
|
|
1285
1959
|
this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 0, gb);
|
|
1286
1960
|
this.groundShadowBindGroup = this.device.createBindGroup({
|
|
1287
1961
|
label: "ground shadow bind",
|
|
@@ -1300,7 +1974,7 @@ export class Engine {
|
|
|
1300
1974
|
if (!this.shadowLightVPDirty)
|
|
1301
1975
|
return;
|
|
1302
1976
|
this.shadowLightVPDirty = false;
|
|
1303
|
-
const dir = new Vec3(this.
|
|
1977
|
+
const dir = new Vec3(this.sun.direction.x, this.sun.direction.y, this.sun.direction.z);
|
|
1304
1978
|
dir.normalize();
|
|
1305
1979
|
const target = new Vec3(0, 11, 0);
|
|
1306
1980
|
const eye = new Vec3(target.x - dir.x * 72, target.y - dir.y * 72, target.z - dir.z * 72);
|
|
@@ -1338,7 +2012,11 @@ export class Engine {
|
|
|
1338
2012
|
throw new Error(`Material "${mat.name}" has no diffuse texture`);
|
|
1339
2013
|
const materialAlpha = mat.diffuse[3];
|
|
1340
2014
|
const isTransparent = materialAlpha < 1.0 - 0.001;
|
|
1341
|
-
const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, materialAlpha, [
|
|
2015
|
+
const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, materialAlpha, [
|
|
2016
|
+
mat.diffuse[0],
|
|
2017
|
+
mat.diffuse[1],
|
|
2018
|
+
mat.diffuse[2],
|
|
2019
|
+
]);
|
|
1342
2020
|
inst.gpuBuffers.push(materialUniformBuffer);
|
|
1343
2021
|
const textureView = diffuseTexture.createView();
|
|
1344
2022
|
const bindGroup = this.device.createBindGroup({
|
|
@@ -1350,23 +2028,42 @@ export class Engine {
|
|
|
1350
2028
|
],
|
|
1351
2029
|
});
|
|
1352
2030
|
const type = isTransparent ? "transparent" : "opaque";
|
|
1353
|
-
|
|
2031
|
+
const preset = resolvePreset(mat.name, inst.materialPresets);
|
|
2032
|
+
inst.drawCalls.push({
|
|
2033
|
+
type,
|
|
2034
|
+
count: indexCount,
|
|
2035
|
+
firstIndex: currentIndexOffset,
|
|
2036
|
+
bindGroup,
|
|
2037
|
+
materialName: mat.name,
|
|
2038
|
+
preset,
|
|
2039
|
+
});
|
|
1354
2040
|
if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
|
|
1355
2041
|
const materialUniformData = new Float32Array([
|
|
1356
|
-
mat.edgeColor[0],
|
|
1357
|
-
mat.
|
|
2042
|
+
mat.edgeColor[0],
|
|
2043
|
+
mat.edgeColor[1],
|
|
2044
|
+
mat.edgeColor[2],
|
|
2045
|
+
mat.edgeColor[3],
|
|
2046
|
+
mat.edgeSize,
|
|
2047
|
+
0,
|
|
2048
|
+
0,
|
|
2049
|
+
0,
|
|
1358
2050
|
]);
|
|
1359
2051
|
const outlineUniformBuffer = this.createUniformBuffer(`${prefix}outline: ${mat.name}`, materialUniformData);
|
|
1360
2052
|
inst.gpuBuffers.push(outlineUniformBuffer);
|
|
1361
2053
|
const outlineBindGroup = this.device.createBindGroup({
|
|
1362
2054
|
label: `${prefix}outline: ${mat.name}`,
|
|
1363
2055
|
layout: this.outlinePerMaterialBindGroupLayout,
|
|
1364
|
-
entries: [
|
|
1365
|
-
{ binding: 0, resource: { buffer: outlineUniformBuffer } },
|
|
1366
|
-
],
|
|
2056
|
+
entries: [{ binding: 0, resource: { buffer: outlineUniformBuffer } }],
|
|
1367
2057
|
});
|
|
1368
2058
|
const outlineType = isTransparent ? "transparent-outline" : "opaque-outline";
|
|
1369
|
-
inst.drawCalls.push({
|
|
2059
|
+
inst.drawCalls.push({
|
|
2060
|
+
type: outlineType,
|
|
2061
|
+
count: indexCount,
|
|
2062
|
+
firstIndex: currentIndexOffset,
|
|
2063
|
+
bindGroup: outlineBindGroup,
|
|
2064
|
+
materialName: mat.name,
|
|
2065
|
+
preset,
|
|
2066
|
+
});
|
|
1370
2067
|
}
|
|
1371
2068
|
if (this.onRaycast) {
|
|
1372
2069
|
const pickIdData = new Float32Array([modelId, materialId, 0, 0]);
|
|
@@ -1386,18 +2083,13 @@ export class Engine {
|
|
|
1386
2083
|
inst.shadowDrawCalls.push(d);
|
|
1387
2084
|
}
|
|
1388
2085
|
}
|
|
1389
|
-
createMaterialUniformBuffer(label, alpha, diffuseColor
|
|
1390
|
-
|
|
1391
|
-
data
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
1.0, 1.0, 1.0, 0.0, // rimColor (vec3), _padding2
|
|
1397
|
-
diffuseColor[0], diffuseColor[1], diffuseColor[2], 0.0,
|
|
1398
|
-
ambientColor[0], ambientColor[1], ambientColor[2], 0.0,
|
|
1399
|
-
specularColor[0], specularColor[1], specularColor[2], 0.0,
|
|
1400
|
-
]);
|
|
2086
|
+
createMaterialUniformBuffer(label, alpha, diffuseColor) {
|
|
2087
|
+
// Matches WGSL `struct MaterialUniforms { diffuseColor: vec3f, alpha: f32 }` — 16 bytes.
|
|
2088
|
+
const data = new Float32Array(4);
|
|
2089
|
+
data[0] = diffuseColor[0];
|
|
2090
|
+
data[1] = diffuseColor[1];
|
|
2091
|
+
data[2] = diffuseColor[2];
|
|
2092
|
+
data[3] = alpha;
|
|
1401
2093
|
return this.createUniformBuffer(`material uniform: ${label}`, data);
|
|
1402
2094
|
}
|
|
1403
2095
|
createUniformBuffer(label, data) {
|
|
@@ -1427,7 +2119,7 @@ export class Engine {
|
|
|
1427
2119
|
const texture = this.device.createTexture({
|
|
1428
2120
|
label: `texture: ${cacheKey}`,
|
|
1429
2121
|
size: [imageBitmap.width, imageBitmap.height],
|
|
1430
|
-
format: "rgba8unorm",
|
|
2122
|
+
format: "rgba8unorm-srgb",
|
|
1431
2123
|
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
1432
2124
|
});
|
|
1433
2125
|
this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
|
|
@@ -1463,12 +2155,14 @@ export class Engine {
|
|
|
1463
2155
|
if (!this.pendingPick || !this.pickTexture || !this.pickDepthTexture)
|
|
1464
2156
|
return;
|
|
1465
2157
|
const pass = encoder.beginRenderPass({
|
|
1466
|
-
colorAttachments: [
|
|
2158
|
+
colorAttachments: [
|
|
2159
|
+
{
|
|
1467
2160
|
view: this.pickTexture.createView(),
|
|
1468
2161
|
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
1469
2162
|
loadOp: "clear",
|
|
1470
2163
|
storeOp: "store",
|
|
1471
|
-
}
|
|
2164
|
+
},
|
|
2165
|
+
],
|
|
1472
2166
|
depthStencilAttachment: {
|
|
1473
2167
|
view: this.pickDepthTexture.createView(),
|
|
1474
2168
|
depthClearValue: 1.0,
|
|
@@ -1543,7 +2237,6 @@ export class Engine {
|
|
|
1543
2237
|
const currentTime = performance.now();
|
|
1544
2238
|
const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016;
|
|
1545
2239
|
this.lastFrameTime = currentTime;
|
|
1546
|
-
this.updateRenderTarget();
|
|
1547
2240
|
const hasModels = this.modelInstances.size > 0;
|
|
1548
2241
|
if (hasModels) {
|
|
1549
2242
|
this.updateInstances(deltaTime);
|
|
@@ -1560,10 +2253,9 @@ export class Engine {
|
|
|
1560
2253
|
}
|
|
1561
2254
|
}
|
|
1562
2255
|
this.updateCameraUniforms();
|
|
1563
|
-
|
|
1564
|
-
this.updateShadowLightVP();
|
|
2256
|
+
this.updateShadowLightVP();
|
|
1565
2257
|
const encoder = this.device.createCommandEncoder();
|
|
1566
|
-
if (hasModels
|
|
2258
|
+
if (hasModels) {
|
|
1567
2259
|
const sp = encoder.beginRenderPass({
|
|
1568
2260
|
colorAttachments: [],
|
|
1569
2261
|
depthStencilAttachment: {
|
|
@@ -1583,6 +2275,51 @@ export class Engine {
|
|
|
1583
2275
|
if (this.hasGround)
|
|
1584
2276
|
this.renderGround(pass);
|
|
1585
2277
|
pass.end();
|
|
2278
|
+
// Bloom pyramid (EEVEE 3.6):
|
|
2279
|
+
// 1. Blit: HDR → bloomDown[0] (Karis prefilter, half-res)
|
|
2280
|
+
// 2. Downsample: bloomDown[0] → bloomDown[1] → … → bloomDown[N-1] (13-tap)
|
|
2281
|
+
// 3. Upsample (top-down): bloomUp[N-2] = tent(bloomDown[N-1]) + bloomDown[N-2],
|
|
2282
|
+
// then bloomUp[i] = tent(bloomUp[i+1]) + bloomDown[i] until i=0 (9-tap tent)
|
|
2283
|
+
// Composite reads bloomUp[0] and adds tint * intensity * bloom before Filmic.
|
|
2284
|
+
if (this.bloomBlitBindGroup && this.compositeBindGroup && this.bloomMipCount > 0) {
|
|
2285
|
+
const bloomAtt = this.bloomPassDescriptor.colorAttachments;
|
|
2286
|
+
// 1. Blit
|
|
2287
|
+
bloomAtt[0].view = this.bloomDownMipViews[0];
|
|
2288
|
+
const pBlit = encoder.beginRenderPass(this.bloomPassDescriptor);
|
|
2289
|
+
pBlit.setPipeline(this.bloomBlitPipeline);
|
|
2290
|
+
pBlit.setBindGroup(0, this.bloomBlitBindGroup);
|
|
2291
|
+
pBlit.draw(3);
|
|
2292
|
+
pBlit.end();
|
|
2293
|
+
// 2. Downsample chain
|
|
2294
|
+
for (let i = 1; i < this.bloomMipCount; i++) {
|
|
2295
|
+
bloomAtt[0].view = this.bloomDownMipViews[i];
|
|
2296
|
+
const p = encoder.beginRenderPass(this.bloomPassDescriptor);
|
|
2297
|
+
p.setPipeline(this.bloomDownsamplePipeline);
|
|
2298
|
+
p.setBindGroup(0, this.bloomDownsampleBindGroups[i - 1]);
|
|
2299
|
+
p.draw(3);
|
|
2300
|
+
p.end();
|
|
2301
|
+
}
|
|
2302
|
+
// 3. Upsample chain (coarsest to finest; bindGroups[0] is the coarsest step)
|
|
2303
|
+
const upSteps = this.bloomUpsampleBindGroups.length;
|
|
2304
|
+
const topIdx = this.bloomMipCount - 2;
|
|
2305
|
+
for (let k = 0; k < upSteps; k++) {
|
|
2306
|
+
const levelIdx = topIdx - k; // writes bloomUp[levelIdx]
|
|
2307
|
+
bloomAtt[0].view = this.bloomUpMipViews[levelIdx];
|
|
2308
|
+
const p = encoder.beginRenderPass(this.bloomPassDescriptor);
|
|
2309
|
+
p.setPipeline(this.bloomUpsamplePipeline);
|
|
2310
|
+
p.setBindGroup(0, this.bloomUpsampleBindGroups[k]);
|
|
2311
|
+
p.draw(3);
|
|
2312
|
+
p.end();
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
// Composite: HDR + bloom → Filmic tonemap → swapchain.
|
|
2316
|
+
const compositeAttachment = this.compositePassDescriptor.colorAttachments[0];
|
|
2317
|
+
compositeAttachment.view = this.context.getCurrentTexture().createView();
|
|
2318
|
+
const cpass = encoder.beginRenderPass(this.compositePassDescriptor);
|
|
2319
|
+
cpass.setPipeline(this.compositePipeline);
|
|
2320
|
+
cpass.setBindGroup(0, this.compositeBindGroup);
|
|
2321
|
+
cpass.draw(3);
|
|
2322
|
+
cpass.end();
|
|
1586
2323
|
const pick = this.pendingPick;
|
|
1587
2324
|
if (pick && hasModels)
|
|
1588
2325
|
this.renderPickPass(encoder);
|
|
@@ -1594,10 +2331,6 @@ export class Engine {
|
|
|
1594
2331
|
}
|
|
1595
2332
|
this.updateStats(performance.now() - currentTime);
|
|
1596
2333
|
}
|
|
1597
|
-
updateRenderTarget() {
|
|
1598
|
-
const colorAttachment = this.renderPassDescriptor.colorAttachments[0];
|
|
1599
|
-
colorAttachment.resolveTarget = this.context.getCurrentTexture().createView();
|
|
1600
|
-
}
|
|
1601
2334
|
drawInstanceShadow(sp, inst) {
|
|
1602
2335
|
sp.setBindGroup(0, inst.shadowBindGroup);
|
|
1603
2336
|
sp.setVertexBuffer(0, inst.vertexBuffer);
|
|
@@ -1609,39 +2342,87 @@ export class Engine {
|
|
|
1609
2342
|
sp.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
1610
2343
|
}
|
|
1611
2344
|
}
|
|
1612
|
-
|
|
1613
|
-
|
|
2345
|
+
pipelineForPreset(preset) {
|
|
2346
|
+
if (preset === "face")
|
|
2347
|
+
return this.facePipeline;
|
|
2348
|
+
if (preset === "hair")
|
|
2349
|
+
return this.hairPipeline;
|
|
2350
|
+
if (preset === "cloth_smooth")
|
|
2351
|
+
return this.clothSmoothPipeline;
|
|
2352
|
+
if (preset === "cloth_rough")
|
|
2353
|
+
return this.clothRoughPipeline;
|
|
2354
|
+
if (preset === "metal")
|
|
2355
|
+
return this.metalPipeline;
|
|
2356
|
+
if (preset === "body")
|
|
2357
|
+
return this.bodyPipeline;
|
|
2358
|
+
if (preset === "eye")
|
|
2359
|
+
return this.eyePipeline;
|
|
2360
|
+
if (preset === "stockings")
|
|
2361
|
+
return this.stockingsPipeline;
|
|
2362
|
+
return this.modelPipeline;
|
|
2363
|
+
}
|
|
2364
|
+
/**
|
|
2365
|
+
* Draw every material of a given type (`opaque` or `transparent`) using the main
|
|
2366
|
+
* pipeline(s). Binds the per-frame and per-instance groups once at the top of the
|
|
2367
|
+
* batch, then issues one draw per material. Early-outs if nothing to draw so we
|
|
2368
|
+
* don't waste bindings when a model has no transparents, etc.
|
|
2369
|
+
*/
|
|
2370
|
+
drawMaterials(pass, inst, type) {
|
|
2371
|
+
let currentPipeline = null;
|
|
2372
|
+
let bound = false;
|
|
1614
2373
|
for (const draw of inst.drawCalls) {
|
|
1615
|
-
if (draw.type
|
|
1616
|
-
|
|
1617
|
-
|
|
2374
|
+
if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw))
|
|
2375
|
+
continue;
|
|
2376
|
+
if (!bound) {
|
|
2377
|
+
pass.setBindGroup(0, this.perFrameBindGroup);
|
|
2378
|
+
pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
|
|
2379
|
+
bound = true;
|
|
2380
|
+
}
|
|
2381
|
+
const pipeline = this.pipelineForPreset(draw.preset);
|
|
2382
|
+
if (pipeline !== currentPipeline) {
|
|
2383
|
+
pass.setPipeline(pipeline);
|
|
2384
|
+
currentPipeline = pipeline;
|
|
1618
2385
|
}
|
|
2386
|
+
pass.setBindGroup(2, draw.bindGroup);
|
|
2387
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
1619
2388
|
}
|
|
1620
2389
|
}
|
|
1621
|
-
|
|
1622
|
-
|
|
2390
|
+
/**
|
|
2391
|
+
* Draw every outline of a given type (`opaque-outline` or `transparent-outline`).
|
|
2392
|
+
* Uses its own pipeline layout (group 0 = camera-only, group 2 = edge uniforms), so
|
|
2393
|
+
* every batch binds its own groups from scratch — the next drawMaterials call will
|
|
2394
|
+
* rebind group 0/1 correctly if needed.
|
|
2395
|
+
*/
|
|
2396
|
+
drawOutlines(pass, inst, type) {
|
|
2397
|
+
let bound = false;
|
|
1623
2398
|
for (const draw of inst.drawCalls) {
|
|
1624
|
-
if (draw.type
|
|
1625
|
-
|
|
1626
|
-
|
|
2399
|
+
if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw))
|
|
2400
|
+
continue;
|
|
2401
|
+
if (!bound) {
|
|
2402
|
+
pass.setPipeline(this.outlinePipeline);
|
|
2403
|
+
pass.setBindGroup(0, this.outlinePerFrameBindGroup);
|
|
2404
|
+
pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
|
|
2405
|
+
bound = true;
|
|
1627
2406
|
}
|
|
2407
|
+
pass.setBindGroup(2, draw.bindGroup);
|
|
2408
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
1628
2409
|
}
|
|
1629
2410
|
}
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
2411
|
+
/**
|
|
2412
|
+
* Main-pass render sequence for one model instance:
|
|
2413
|
+
* 1) opaque bodies → 2) opaque outlines → 3) transparents → 4) transparent outlines.
|
|
2414
|
+
* Each batch binds the groups it needs, so switching between main and outline
|
|
2415
|
+
* pipelines is self-contained (no cross-batch dependencies).
|
|
2416
|
+
*/
|
|
1634
2417
|
renderOneModel(pass, inst) {
|
|
1635
2418
|
pass.setVertexBuffer(0, inst.vertexBuffer);
|
|
1636
2419
|
pass.setVertexBuffer(1, inst.jointsBuffer);
|
|
1637
2420
|
pass.setVertexBuffer(2, inst.weightsBuffer);
|
|
1638
2421
|
pass.setIndexBuffer(inst.indexBuffer, "uint32");
|
|
1639
|
-
this.
|
|
1640
|
-
this.
|
|
1641
|
-
this.
|
|
1642
|
-
this.
|
|
1643
|
-
this.drawTransparent(pass, inst, this.modelPipeline);
|
|
1644
|
-
this.drawOutlines(pass, inst, true);
|
|
2422
|
+
this.drawMaterials(pass, inst, "opaque");
|
|
2423
|
+
this.drawOutlines(pass, inst, "opaque-outline");
|
|
2424
|
+
this.drawMaterials(pass, inst, "transparent");
|
|
2425
|
+
this.drawOutlines(pass, inst, "transparent-outline");
|
|
1645
2426
|
}
|
|
1646
2427
|
updateCameraUniforms() {
|
|
1647
2428
|
const viewMatrix = this.camera.getViewMatrix();
|
|
@@ -1660,18 +2441,6 @@ export class Engine {
|
|
|
1660
2441
|
this.device.queue.writeBuffer(inst.skinMatrixBuffer, 0, skinMatrices.buffer, skinMatrices.byteOffset, skinMatrices.byteLength);
|
|
1661
2442
|
});
|
|
1662
2443
|
}
|
|
1663
|
-
drawOutlines(pass, inst, transparent) {
|
|
1664
|
-
pass.setPipeline(this.outlinePipeline);
|
|
1665
|
-
pass.setBindGroup(0, this.outlinePerFrameBindGroup);
|
|
1666
|
-
pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
|
|
1667
|
-
const outlineType = transparent ? "transparent-outline" : "opaque-outline";
|
|
1668
|
-
for (const draw of inst.drawCalls) {
|
|
1669
|
-
if (draw.type === outlineType && this.shouldRenderDrawCall(inst, draw)) {
|
|
1670
|
-
pass.setBindGroup(2, draw.bindGroup);
|
|
1671
|
-
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
1672
|
-
}
|
|
1673
|
-
}
|
|
1674
|
-
}
|
|
1675
2444
|
updateStats(frameTime) {
|
|
1676
2445
|
// Simplified frame time tracking - rolling average with fixed window
|
|
1677
2446
|
const maxSamples = 60;
|
|
@@ -1697,3 +2466,6 @@ export class Engine {
|
|
|
1697
2466
|
}
|
|
1698
2467
|
Engine.instance = null;
|
|
1699
2468
|
Engine.MULTISAMPLE_COUNT = 4;
|
|
2469
|
+
Engine.HDR_FORMAT = "rgba16float";
|
|
2470
|
+
Engine.BLOOM_MAX_LEVELS = 7;
|
|
2471
|
+
Engine.SHADOW_MAP_SIZE = 4096;
|