reze-engine 0.10.2 → 0.11.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 +90 -13
- package/dist/engine.d.ts +177 -34
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +1204 -318
- 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 +232 -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 +190 -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 +186 -0
- package/dist/shaders/default.d.ts +2 -0
- package/dist/shaders/default.d.ts.map +1 -0
- package/dist/shaders/default.js +185 -0
- package/dist/shaders/dfg_lut.d.ts +3 -0
- package/dist/shaders/dfg_lut.d.ts.map +1 -0
- package/dist/shaders/dfg_lut.js +129 -0
- package/dist/shaders/eye.d.ts +2 -0
- package/dist/shaders/eye.d.ts.map +1 -0
- package/dist/shaders/eye.js +159 -0
- package/dist/shaders/face.d.ts +2 -0
- package/dist/shaders/face.d.ts.map +1 -0
- package/dist/shaders/face.js +235 -0
- package/dist/shaders/hair.d.ts +2 -0
- package/dist/shaders/hair.d.ts.map +1 -0
- package/dist/shaders/hair.js +196 -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 +187 -0
- package/dist/shaders/nodes.d.ts +2 -0
- package/dist/shaders/nodes.d.ts.map +1 -0
- package/dist/shaders/nodes.js +465 -0
- package/dist/shaders/stockings.d.ts +2 -0
- package/dist/shaders/stockings.d.ts.map +1 -0
- package/dist/shaders/stockings.js +244 -0
- package/package.json +1 -1
- package/src/engine.ts +1412 -385
- package/src/index.ts +12 -2
- package/src/shaders/body.ts +234 -0
- package/src/shaders/classify.ts +25 -0
- package/src/shaders/cloth_rough.ts +192 -0
- package/src/shaders/cloth_smooth.ts +188 -0
- package/src/shaders/default.ts +186 -0
- package/src/shaders/dfg_lut.ts +131 -0
- package/src/shaders/eye.ts +160 -0
- package/src/shaders/face.ts +237 -0
- package/src/shaders/hair.ts +198 -0
- package/src/shaders/ltc_mag_lut.ts +1035 -0
- package/src/shaders/metal.ts +189 -0
- package/src/shaders/nodes.ts +466 -0
- package/src/shaders/stockings.ts +246 -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 { BRDF_LUT_SIZE, BRDF_LUT_BAKE_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.05,
|
|
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,16 +48,26 @@ 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;
|
|
38
67
|
this.modelInstances = new Map();
|
|
39
68
|
this.textureCache = new Map();
|
|
69
|
+
this.mipBlitPipeline = null;
|
|
70
|
+
this.mipBlitSampler = null;
|
|
40
71
|
this._nextDefaultModelId = 0;
|
|
41
72
|
// IK and physics enabled at engine level (same for all models)
|
|
42
73
|
this.ikEnabled = true;
|
|
@@ -56,7 +87,7 @@ export class Engine {
|
|
|
56
87
|
};
|
|
57
88
|
this.animationFrameId = null;
|
|
58
89
|
this.renderLoopCallback = null;
|
|
59
|
-
// Shadow
|
|
90
|
+
// Shadow is cast from the visible sun direction — same vector the shader lights with.
|
|
60
91
|
this.shadowLightVPDirty = true;
|
|
61
92
|
this.handleCanvasDoubleClick = (event) => {
|
|
62
93
|
if (!this.onRaycast || this.modelInstances.size === 0)
|
|
@@ -90,20 +121,135 @@ export class Engine {
|
|
|
90
121
|
}
|
|
91
122
|
};
|
|
92
123
|
this.canvas = canvas;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
124
|
+
const d = DEFAULT_ENGINE_OPTIONS;
|
|
125
|
+
this.world = {
|
|
126
|
+
color: options?.world?.color ?? d.world.color,
|
|
127
|
+
strength: options?.world?.strength ?? d.world.strength,
|
|
128
|
+
};
|
|
129
|
+
this.sun = {
|
|
130
|
+
color: options?.sun?.color ?? d.sun.color,
|
|
131
|
+
strength: options?.sun?.strength ?? d.sun.strength,
|
|
132
|
+
direction: options?.sun?.direction ?? d.sun.direction,
|
|
133
|
+
};
|
|
134
|
+
this.cameraConfig = {
|
|
135
|
+
distance: options?.camera?.distance ?? d.camera.distance,
|
|
136
|
+
target: options?.camera?.target ?? d.camera.target,
|
|
137
|
+
fov: options?.camera?.fov ?? d.camera.fov,
|
|
138
|
+
};
|
|
139
|
+
this.onRaycast = options?.onRaycast;
|
|
140
|
+
this.physicsOptions = options?.physicsOptions ?? d.physicsOptions;
|
|
141
|
+
this.bloomSettings = Engine.mergeBloomDefaults(options?.bloom);
|
|
142
|
+
this.viewTransform = Engine.mergeViewTransformDefaults(options?.view);
|
|
143
|
+
}
|
|
144
|
+
/** Merge partial bloom with EEVEE defaults (same as constructor). */
|
|
145
|
+
static mergeBloomDefaults(partial) {
|
|
146
|
+
const d = DEFAULT_BLOOM_OPTIONS;
|
|
147
|
+
const c = partial?.color;
|
|
148
|
+
return {
|
|
149
|
+
enabled: partial?.enabled ?? d.enabled,
|
|
150
|
+
threshold: partial?.threshold ?? d.threshold,
|
|
151
|
+
knee: partial?.knee ?? d.knee,
|
|
152
|
+
radius: partial?.radius ?? d.radius,
|
|
153
|
+
color: c ? new Vec3(c.x, c.y, c.z) : new Vec3(d.color.x, d.color.y, d.color.z),
|
|
154
|
+
intensity: partial?.intensity ?? d.intensity,
|
|
155
|
+
clamp: partial?.clamp ?? d.clamp,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
static mergeViewTransformDefaults(partial) {
|
|
159
|
+
const d = DEFAULT_VIEW_TRANSFORM;
|
|
160
|
+
return {
|
|
161
|
+
exposure: partial?.exposure ?? d.exposure,
|
|
162
|
+
gamma: partial?.gamma ?? d.gamma,
|
|
163
|
+
look: partial?.look ?? d.look,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/** Current bloom settings (Blender names; tint is a copied `Vec3`). */
|
|
167
|
+
getBloomOptions() {
|
|
168
|
+
const b = this.bloomSettings;
|
|
169
|
+
return {
|
|
170
|
+
enabled: b.enabled,
|
|
171
|
+
threshold: b.threshold,
|
|
172
|
+
knee: b.knee,
|
|
173
|
+
radius: b.radius,
|
|
174
|
+
color: new Vec3(b.color.x, b.color.y, b.color.z),
|
|
175
|
+
intensity: b.intensity,
|
|
176
|
+
clamp: b.clamp,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
getViewTransformOptions() {
|
|
180
|
+
const v = this.viewTransform;
|
|
181
|
+
return { exposure: v.exposure, gamma: v.gamma, look: v.look };
|
|
182
|
+
}
|
|
183
|
+
setViewTransformOptions(patch) {
|
|
184
|
+
const v = this.viewTransform;
|
|
185
|
+
if (patch.exposure !== undefined)
|
|
186
|
+
v.exposure = patch.exposure;
|
|
187
|
+
if (patch.gamma !== undefined)
|
|
188
|
+
v.gamma = patch.gamma;
|
|
189
|
+
if (patch.look !== undefined)
|
|
190
|
+
v.look = patch.look;
|
|
191
|
+
if (this.device && this.compositeUniformBuffer) {
|
|
192
|
+
this.writeCompositeViewUniforms();
|
|
105
193
|
}
|
|
106
194
|
}
|
|
195
|
+
writeCompositeViewUniforms() {
|
|
196
|
+
const v = this.viewTransform;
|
|
197
|
+
const b = this.bloomSettings;
|
|
198
|
+
const effIntensity = b.enabled ? b.intensity : 0.0;
|
|
199
|
+
const u = this.compositeUniformData;
|
|
200
|
+
u[0] = v.exposure;
|
|
201
|
+
u[1] = Math.max(v.gamma, 1e-4);
|
|
202
|
+
u[2] = 0.0;
|
|
203
|
+
u[3] = 0.0;
|
|
204
|
+
u[4] = b.color.x;
|
|
205
|
+
u[5] = b.color.y;
|
|
206
|
+
u[6] = b.color.z;
|
|
207
|
+
u[7] = effIntensity;
|
|
208
|
+
this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u);
|
|
209
|
+
}
|
|
210
|
+
/** Patch bloom; GPU uniforms update immediately if `init()` has run. */
|
|
211
|
+
setBloomOptions(patch) {
|
|
212
|
+
const b = this.bloomSettings;
|
|
213
|
+
if (patch.enabled !== undefined)
|
|
214
|
+
b.enabled = patch.enabled;
|
|
215
|
+
if (patch.threshold !== undefined)
|
|
216
|
+
b.threshold = patch.threshold;
|
|
217
|
+
if (patch.knee !== undefined)
|
|
218
|
+
b.knee = patch.knee;
|
|
219
|
+
if (patch.radius !== undefined)
|
|
220
|
+
b.radius = patch.radius;
|
|
221
|
+
if (patch.color !== undefined) {
|
|
222
|
+
b.color.x = patch.color.x;
|
|
223
|
+
b.color.y = patch.color.y;
|
|
224
|
+
b.color.z = patch.color.z;
|
|
225
|
+
}
|
|
226
|
+
if (patch.intensity !== undefined)
|
|
227
|
+
b.intensity = patch.intensity;
|
|
228
|
+
if (patch.clamp !== undefined)
|
|
229
|
+
b.clamp = patch.clamp;
|
|
230
|
+
if (this.device && this.bloomBlitUniformBuffer) {
|
|
231
|
+
this.writeBloomUniforms();
|
|
232
|
+
this.writeCompositeViewUniforms();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// EEVEE prefilter uniforms (blit stage) + upsample sample scale. Intensity/tint live in composite.
|
|
236
|
+
writeBloomUniforms() {
|
|
237
|
+
const b = this.bloomSettings;
|
|
238
|
+
const bu = this.bloomBlitUniformData;
|
|
239
|
+
// EEVEE prefilter: threshold, knee, clamp (0 → disabled), _unused
|
|
240
|
+
bu[0] = b.threshold;
|
|
241
|
+
bu[1] = b.knee;
|
|
242
|
+
bu[2] = b.clamp;
|
|
243
|
+
bu[3] = 0.0;
|
|
244
|
+
this.device.queue.writeBuffer(this.bloomBlitUniformBuffer, 0, bu);
|
|
245
|
+
const us = this.bloomUpsampleUniformData;
|
|
246
|
+
// Blender: bloom.radius directly controls the tent-filter sample scale in texel units.
|
|
247
|
+
us[0] = Math.max(0.5, b.radius);
|
|
248
|
+
us[1] = 0;
|
|
249
|
+
us[2] = 0;
|
|
250
|
+
us[3] = 0;
|
|
251
|
+
this.device.queue.writeBuffer(this.bloomUpsampleUniformBuffer, 0, us);
|
|
252
|
+
}
|
|
107
253
|
// Step 1: Get WebGPU device and context
|
|
108
254
|
async init() {
|
|
109
255
|
const adapter = await navigator.gpu?.requestAdapter();
|
|
@@ -129,7 +275,79 @@ export class Engine {
|
|
|
129
275
|
this.setupResize();
|
|
130
276
|
Engine.instance = this;
|
|
131
277
|
}
|
|
278
|
+
// One-shot bake of EEVEE's combined BRDF LUT — DFG (bsdf_lut_frag.glsl) packed
|
|
279
|
+
// with ltc_mag_ggx (eevee_lut.c) into a single 64×64 rgba8unorm texture:
|
|
280
|
+
// .rg = split-sum DFG → F_brdf_*_scatter
|
|
281
|
+
// .ba = LTC magnitude → ltc_brdf_scale_from_lut
|
|
282
|
+
// One texture fetch per fragment replaces the previous 2–3 taps. rgba8unorm
|
|
283
|
+
// (vs rgba16float) halves sample bandwidth; DFG/LTC values fit [0,1] cleanly.
|
|
284
|
+
bakeBrdfLut() {
|
|
285
|
+
if (BRDF_LUT_SIZE !== LTC_MAG_LUT_SIZE) {
|
|
286
|
+
throw new Error("BRDF LUT bake requires DFG size == LTC size (both 64).");
|
|
287
|
+
}
|
|
288
|
+
// Temp rg16float LTC source — loaded 1:1 by the bake fragment shader, then dropped.
|
|
289
|
+
const ltcTemp = this.device.createTexture({
|
|
290
|
+
label: "LTC mag LUT (bake input)",
|
|
291
|
+
size: [LTC_MAG_LUT_SIZE, LTC_MAG_LUT_SIZE],
|
|
292
|
+
format: "rg16float",
|
|
293
|
+
usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
|
|
294
|
+
});
|
|
295
|
+
const n = LTC_MAG_LUT_DATA.length;
|
|
296
|
+
const half = new Uint16Array(n);
|
|
297
|
+
const f32 = new Float32Array(1);
|
|
298
|
+
const u32 = new Uint32Array(f32.buffer);
|
|
299
|
+
for (let i = 0; i < n; i++) {
|
|
300
|
+
f32[0] = LTC_MAG_LUT_DATA[i];
|
|
301
|
+
const x = u32[0];
|
|
302
|
+
const sign = (x >>> 16) & 0x8000;
|
|
303
|
+
let exp = ((x >>> 23) & 0xff) - 127 + 15;
|
|
304
|
+
const mant = x & 0x7fffff;
|
|
305
|
+
if (exp <= 0) {
|
|
306
|
+
half[i] = sign;
|
|
307
|
+
}
|
|
308
|
+
else if (exp >= 31) {
|
|
309
|
+
half[i] = sign | 0x7c00;
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
half[i] = sign | (exp << 10) | (mant >>> 13);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
this.device.queue.writeTexture({ texture: ltcTemp }, half, { bytesPerRow: LTC_MAG_LUT_SIZE * 4, rowsPerImage: LTC_MAG_LUT_SIZE }, { width: LTC_MAG_LUT_SIZE, height: LTC_MAG_LUT_SIZE, depthOrArrayLayers: 1 });
|
|
316
|
+
this.brdfLutTexture = this.device.createTexture({
|
|
317
|
+
label: "BRDF LUT (DFG + LTC packed)",
|
|
318
|
+
size: [BRDF_LUT_SIZE, BRDF_LUT_SIZE],
|
|
319
|
+
format: "rgba8unorm",
|
|
320
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
321
|
+
});
|
|
322
|
+
this.brdfLutView = this.brdfLutTexture.createView();
|
|
323
|
+
const module = this.device.createShaderModule({ label: "BRDF LUT bake", code: BRDF_LUT_BAKE_WGSL });
|
|
324
|
+
const pipeline = this.device.createRenderPipeline({
|
|
325
|
+
label: "BRDF LUT bake pipeline",
|
|
326
|
+
layout: "auto",
|
|
327
|
+
vertex: { module, entryPoint: "vs" },
|
|
328
|
+
fragment: { module, entryPoint: "fs", targets: [{ format: "rgba8unorm" }] },
|
|
329
|
+
primitive: { topology: "triangle-list" },
|
|
330
|
+
});
|
|
331
|
+
const bakeBindGroup = this.device.createBindGroup({
|
|
332
|
+
label: "BRDF LUT bake bind group",
|
|
333
|
+
layout: pipeline.getBindGroupLayout(0),
|
|
334
|
+
entries: [{ binding: 0, resource: ltcTemp.createView() }],
|
|
335
|
+
});
|
|
336
|
+
const enc = this.device.createCommandEncoder({ label: "BRDF LUT bake encoder" });
|
|
337
|
+
const pass = enc.beginRenderPass({
|
|
338
|
+
colorAttachments: [
|
|
339
|
+
{ view: this.brdfLutView, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: "clear", storeOp: "store" },
|
|
340
|
+
],
|
|
341
|
+
});
|
|
342
|
+
pass.setPipeline(pipeline);
|
|
343
|
+
pass.setBindGroup(0, bakeBindGroup);
|
|
344
|
+
pass.draw(3, 1, 0, 0);
|
|
345
|
+
pass.end();
|
|
346
|
+
this.device.queue.submit([enc.finish()]);
|
|
347
|
+
ltcTemp.destroy();
|
|
348
|
+
}
|
|
132
349
|
createRenderPipeline(config) {
|
|
350
|
+
const targets = config.fragmentTargets ?? (config.fragmentTarget ? [config.fragmentTarget] : undefined);
|
|
133
351
|
return this.device.createRenderPipeline({
|
|
134
352
|
label: config.label,
|
|
135
353
|
layout: config.layout,
|
|
@@ -137,11 +355,11 @@ export class Engine {
|
|
|
137
355
|
module: config.shaderModule,
|
|
138
356
|
buffers: config.vertexBuffers,
|
|
139
357
|
},
|
|
140
|
-
fragment:
|
|
358
|
+
fragment: targets
|
|
141
359
|
? {
|
|
142
360
|
module: config.shaderModule,
|
|
143
361
|
entryPoint: config.fragmentEntryPoint,
|
|
144
|
-
targets
|
|
362
|
+
targets,
|
|
145
363
|
}
|
|
146
364
|
: undefined,
|
|
147
365
|
primitive: { cullMode: config.cullMode ?? "none" },
|
|
@@ -153,6 +371,7 @@ export class Engine {
|
|
|
153
371
|
this.materialSampler = this.device.createSampler({
|
|
154
372
|
magFilter: "linear",
|
|
155
373
|
minFilter: "linear",
|
|
374
|
+
mipmapFilter: "linear",
|
|
156
375
|
addressModeU: "repeat",
|
|
157
376
|
addressModeV: "repeat",
|
|
158
377
|
});
|
|
@@ -192,8 +411,11 @@ export class Engine {
|
|
|
192
411
|
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
|
|
193
412
|
},
|
|
194
413
|
];
|
|
414
|
+
// Internal scene passes render into the HDR offscreen target; only the final
|
|
415
|
+
// composite pass writes the swapchain. Tonemap moved to composite so bloom
|
|
416
|
+
// (added next) can run on linear HDR.
|
|
195
417
|
const standardBlend = {
|
|
196
|
-
format:
|
|
418
|
+
format: Engine.HDR_FORMAT,
|
|
197
419
|
blend: {
|
|
198
420
|
color: {
|
|
199
421
|
srcFactor: "src-alpha",
|
|
@@ -207,146 +429,64 @@ export class Engine {
|
|
|
207
429
|
},
|
|
208
430
|
},
|
|
209
431
|
};
|
|
432
|
+
// Bloom mask target — r8unorm has no alpha channel, so src-alpha blending is invalid.
|
|
433
|
+
// Use replace mode: depth test already rejects occluded fragments, so last-writer-wins
|
|
434
|
+
// on surviving pixels gives the right result (ground writes 0; models/outlines write 1).
|
|
435
|
+
const maskBlend = { format: Engine.BLOOM_MASK_FORMAT };
|
|
436
|
+
const sceneTargets = [standardBlend, maskBlend];
|
|
210
437
|
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
|
-
struct MaterialUniforms {
|
|
231
|
-
alpha: f32,
|
|
232
|
-
rimIntensity: f32,
|
|
233
|
-
shininess: f32,
|
|
234
|
-
_padding1: f32,
|
|
235
|
-
rimColor: vec3f,
|
|
236
|
-
_padding2: f32,
|
|
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
|
-
`,
|
|
438
|
+
label: "default model shader",
|
|
439
|
+
code: DEFAULT_SHADER_WGSL,
|
|
440
|
+
});
|
|
441
|
+
const faceShaderModule = this.device.createShaderModule({
|
|
442
|
+
label: "face NPR shader",
|
|
443
|
+
code: FACE_SHADER_WGSL,
|
|
444
|
+
});
|
|
445
|
+
const hairShaderModule = this.device.createShaderModule({
|
|
446
|
+
label: "hair NPR shader",
|
|
447
|
+
code: HAIR_SHADER_WGSL,
|
|
448
|
+
});
|
|
449
|
+
const clothSmoothShaderModule = this.device.createShaderModule({
|
|
450
|
+
label: "cloth smooth NPR shader",
|
|
451
|
+
code: CLOTH_SMOOTH_SHADER_WGSL,
|
|
452
|
+
});
|
|
453
|
+
const clothRoughShaderModule = this.device.createShaderModule({
|
|
454
|
+
label: "cloth rough NPR shader",
|
|
455
|
+
code: CLOTH_ROUGH_SHADER_WGSL,
|
|
334
456
|
});
|
|
335
|
-
|
|
457
|
+
const metalShaderModule = this.device.createShaderModule({
|
|
458
|
+
label: "metal NPR shader",
|
|
459
|
+
code: METAL_SHADER_WGSL,
|
|
460
|
+
});
|
|
461
|
+
const bodyShaderModule = this.device.createShaderModule({
|
|
462
|
+
label: "body NPR shader",
|
|
463
|
+
code: BODY_SHADER_WGSL,
|
|
464
|
+
});
|
|
465
|
+
const eyeShaderModule = this.device.createShaderModule({
|
|
466
|
+
label: "eye shader",
|
|
467
|
+
code: EYE_SHADER_WGSL,
|
|
468
|
+
});
|
|
469
|
+
const stockingsShaderModule = this.device.createShaderModule({
|
|
470
|
+
label: "stockings NPR shader",
|
|
471
|
+
code: STOCKINGS_SHADER_WGSL,
|
|
472
|
+
});
|
|
473
|
+
// group 0: per-frame (camera + light + sampler + shadow) — bound once per pass
|
|
336
474
|
this.mainPerFrameBindGroupLayout = this.device.createBindGroupLayout({
|
|
337
475
|
label: "main per-frame bind group layout",
|
|
338
476
|
entries: [
|
|
339
477
|
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
340
478
|
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
341
479
|
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
480
|
+
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
|
|
481
|
+
{ binding: 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" } },
|
|
482
|
+
{ binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
483
|
+
{ binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
342
484
|
],
|
|
343
485
|
});
|
|
344
486
|
// group 1: per-instance (skinMats) — bound once per model
|
|
345
487
|
this.mainPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
|
|
346
488
|
label: "main per-instance bind group layout",
|
|
347
|
-
entries: [
|
|
348
|
-
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
349
|
-
],
|
|
489
|
+
entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }],
|
|
350
490
|
});
|
|
351
491
|
// group 2: per-material (texture + material uniforms) — bound per draw call
|
|
352
492
|
this.mainPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
|
|
@@ -358,23 +498,123 @@ export class Engine {
|
|
|
358
498
|
});
|
|
359
499
|
const mainPipelineLayout = this.device.createPipelineLayout({
|
|
360
500
|
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 },
|
|
501
|
+
bindGroupLayouts: [
|
|
502
|
+
this.mainPerFrameBindGroupLayout,
|
|
503
|
+
this.mainPerInstanceBindGroupLayout,
|
|
504
|
+
this.mainPerMaterialBindGroupLayout,
|
|
370
505
|
],
|
|
371
506
|
});
|
|
507
|
+
// perFrameBindGroup is created after shadow resources below
|
|
372
508
|
this.modelPipeline = this.createRenderPipeline({
|
|
373
509
|
label: "model pipeline",
|
|
374
510
|
layout: mainPipelineLayout,
|
|
375
511
|
shaderModule,
|
|
376
512
|
vertexBuffers: fullVertexBuffers,
|
|
377
|
-
|
|
513
|
+
fragmentTargets: sceneTargets,
|
|
514
|
+
cullMode: "none",
|
|
515
|
+
depthStencil: {
|
|
516
|
+
format: "depth24plus-stencil8",
|
|
517
|
+
depthWriteEnabled: true,
|
|
518
|
+
depthCompare: "less-equal",
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
this.facePipeline = this.createRenderPipeline({
|
|
522
|
+
label: "face NPR pipeline",
|
|
523
|
+
layout: mainPipelineLayout,
|
|
524
|
+
shaderModule: faceShaderModule,
|
|
525
|
+
vertexBuffers: fullVertexBuffers,
|
|
526
|
+
fragmentTargets: sceneTargets,
|
|
527
|
+
cullMode: "none",
|
|
528
|
+
depthStencil: {
|
|
529
|
+
format: "depth24plus-stencil8",
|
|
530
|
+
depthWriteEnabled: true,
|
|
531
|
+
depthCompare: "less-equal",
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
this.hairPipeline = this.createRenderPipeline({
|
|
535
|
+
label: "hair NPR pipeline",
|
|
536
|
+
layout: mainPipelineLayout,
|
|
537
|
+
shaderModule: hairShaderModule,
|
|
538
|
+
vertexBuffers: fullVertexBuffers,
|
|
539
|
+
fragmentTargets: sceneTargets,
|
|
540
|
+
cullMode: "none",
|
|
541
|
+
depthStencil: {
|
|
542
|
+
format: "depth24plus-stencil8",
|
|
543
|
+
depthWriteEnabled: true,
|
|
544
|
+
depthCompare: "less-equal",
|
|
545
|
+
},
|
|
546
|
+
});
|
|
547
|
+
this.clothSmoothPipeline = this.createRenderPipeline({
|
|
548
|
+
label: "cloth smooth NPR pipeline",
|
|
549
|
+
layout: mainPipelineLayout,
|
|
550
|
+
shaderModule: clothSmoothShaderModule,
|
|
551
|
+
vertexBuffers: fullVertexBuffers,
|
|
552
|
+
fragmentTargets: sceneTargets,
|
|
553
|
+
cullMode: "none",
|
|
554
|
+
depthStencil: {
|
|
555
|
+
format: "depth24plus-stencil8",
|
|
556
|
+
depthWriteEnabled: true,
|
|
557
|
+
depthCompare: "less-equal",
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
this.clothRoughPipeline = this.createRenderPipeline({
|
|
561
|
+
label: "cloth rough NPR pipeline",
|
|
562
|
+
layout: mainPipelineLayout,
|
|
563
|
+
shaderModule: clothRoughShaderModule,
|
|
564
|
+
vertexBuffers: fullVertexBuffers,
|
|
565
|
+
fragmentTargets: sceneTargets,
|
|
566
|
+
cullMode: "none",
|
|
567
|
+
depthStencil: {
|
|
568
|
+
format: "depth24plus-stencil8",
|
|
569
|
+
depthWriteEnabled: true,
|
|
570
|
+
depthCompare: "less-equal",
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
this.metalPipeline = this.createRenderPipeline({
|
|
574
|
+
label: "metal NPR pipeline",
|
|
575
|
+
layout: mainPipelineLayout,
|
|
576
|
+
shaderModule: metalShaderModule,
|
|
577
|
+
vertexBuffers: fullVertexBuffers,
|
|
578
|
+
fragmentTargets: sceneTargets,
|
|
579
|
+
cullMode: "none",
|
|
580
|
+
depthStencil: {
|
|
581
|
+
format: "depth24plus-stencil8",
|
|
582
|
+
depthWriteEnabled: true,
|
|
583
|
+
depthCompare: "less-equal",
|
|
584
|
+
},
|
|
585
|
+
});
|
|
586
|
+
this.bodyPipeline = this.createRenderPipeline({
|
|
587
|
+
label: "body NPR pipeline",
|
|
588
|
+
layout: mainPipelineLayout,
|
|
589
|
+
shaderModule: bodyShaderModule,
|
|
590
|
+
vertexBuffers: fullVertexBuffers,
|
|
591
|
+
fragmentTargets: sceneTargets,
|
|
592
|
+
cullMode: "none",
|
|
593
|
+
depthStencil: {
|
|
594
|
+
format: "depth24plus-stencil8",
|
|
595
|
+
depthWriteEnabled: true,
|
|
596
|
+
depthCompare: "less-equal",
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
this.eyePipeline = this.createRenderPipeline({
|
|
600
|
+
label: "eye pipeline",
|
|
601
|
+
layout: mainPipelineLayout,
|
|
602
|
+
shaderModule: eyeShaderModule,
|
|
603
|
+
vertexBuffers: fullVertexBuffers,
|
|
604
|
+
fragmentTargets: sceneTargets,
|
|
605
|
+
cullMode: "none",
|
|
606
|
+
depthStencil: {
|
|
607
|
+
format: "depth24plus-stencil8",
|
|
608
|
+
depthWriteEnabled: true,
|
|
609
|
+
depthCompare: "less-equal",
|
|
610
|
+
},
|
|
611
|
+
});
|
|
612
|
+
this.stockingsPipeline = this.createRenderPipeline({
|
|
613
|
+
label: "stockings NPR pipeline",
|
|
614
|
+
layout: mainPipelineLayout,
|
|
615
|
+
shaderModule: stockingsShaderModule,
|
|
616
|
+
vertexBuffers: fullVertexBuffers,
|
|
617
|
+
fragmentTargets: sceneTargets,
|
|
378
618
|
cullMode: "none",
|
|
379
619
|
depthStencil: {
|
|
380
620
|
format: "depth24plus-stencil8",
|
|
@@ -430,6 +670,29 @@ export class Engine {
|
|
|
430
670
|
magFilter: "linear",
|
|
431
671
|
minFilter: "linear",
|
|
432
672
|
});
|
|
673
|
+
this.shadowMapTexture = this.device.createTexture({
|
|
674
|
+
label: "shadow map",
|
|
675
|
+
size: [Engine.SHADOW_MAP_SIZE, Engine.SHADOW_MAP_SIZE],
|
|
676
|
+
format: "depth32float",
|
|
677
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
678
|
+
});
|
|
679
|
+
this.shadowMapDepthView = this.shadowMapTexture.createView();
|
|
680
|
+
// One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
|
|
681
|
+
this.bakeBrdfLut();
|
|
682
|
+
// Now that shadow resources exist, create the main per-frame bind group
|
|
683
|
+
this.perFrameBindGroup = this.device.createBindGroup({
|
|
684
|
+
label: "main per-frame bind group",
|
|
685
|
+
layout: this.mainPerFrameBindGroupLayout,
|
|
686
|
+
entries: [
|
|
687
|
+
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
688
|
+
{ binding: 1, resource: { buffer: this.lightUniformBuffer } },
|
|
689
|
+
{ binding: 2, resource: this.materialSampler },
|
|
690
|
+
{ binding: 3, resource: this.shadowMapDepthView },
|
|
691
|
+
{ binding: 4, resource: this.shadowComparisonSampler },
|
|
692
|
+
{ binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
|
|
693
|
+
{ binding: 9, resource: this.brdfLutView },
|
|
694
|
+
],
|
|
695
|
+
});
|
|
433
696
|
this.groundShadowBindGroupLayout = this.device.createBindGroupLayout({
|
|
434
697
|
label: "ground shadow layout",
|
|
435
698
|
entries: [
|
|
@@ -491,7 +754,8 @@ export class Engine {
|
|
|
491
754
|
var o: VO; o.worldPos = position; o.normal = normal;
|
|
492
755
|
o.position = camera.projection * camera.view * vec4f(position, 1.0); return o;
|
|
493
756
|
}
|
|
494
|
-
|
|
757
|
+
struct FSOut { @location(0) color: vec4f, @location(1) mask: f32 };
|
|
758
|
+
@fragment fn fs(i: VO) -> FSOut {
|
|
495
759
|
let n = normalize(i.normal);
|
|
496
760
|
let centerDist = length(i.worldPos.xz);
|
|
497
761
|
let edgeFade = 1.0 - smoothstep(0.0, 1.0, clamp((centerDist - material.fadeStart) / max(material.fadeEnd - material.fadeStart, 0.001), 0.0, 1.0));
|
|
@@ -529,7 +793,10 @@ export class Engine {
|
|
|
529
793
|
var baseColor = material.diffuseColor * sun * (1.0 - dark * 0.65);
|
|
530
794
|
baseColor *= noiseTint;
|
|
531
795
|
let finalColor = mix(baseColor, material.gridLineColor, gridLine * material.gridLineOpacity * edgeFade);
|
|
532
|
-
|
|
796
|
+
var out: FSOut;
|
|
797
|
+
out.color = vec4f(finalColor * edgeFade, edgeFade);
|
|
798
|
+
out.mask = 0.0;
|
|
799
|
+
return out;
|
|
533
800
|
}
|
|
534
801
|
`,
|
|
535
802
|
});
|
|
@@ -538,7 +805,7 @@ export class Engine {
|
|
|
538
805
|
layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.groundShadowBindGroupLayout] }),
|
|
539
806
|
shaderModule: groundShadowShader,
|
|
540
807
|
vertexBuffers: fullVertexBuffers,
|
|
541
|
-
|
|
808
|
+
fragmentTargets: sceneTargets,
|
|
542
809
|
cullMode: "back",
|
|
543
810
|
depthStencil: { format: "depth24plus-stencil8", depthWriteEnabled: true, depthCompare: "less-equal" },
|
|
544
811
|
});
|
|
@@ -558,14 +825,16 @@ export class Engine {
|
|
|
558
825
|
});
|
|
559
826
|
const outlinePipelineLayout = this.device.createPipelineLayout({
|
|
560
827
|
label: "outline pipeline layout",
|
|
561
|
-
bindGroupLayouts: [
|
|
828
|
+
bindGroupLayouts: [
|
|
829
|
+
this.outlinePerFrameBindGroupLayout,
|
|
830
|
+
this.mainPerInstanceBindGroupLayout,
|
|
831
|
+
this.outlinePerMaterialBindGroupLayout,
|
|
832
|
+
],
|
|
562
833
|
});
|
|
563
834
|
this.outlinePerFrameBindGroup = this.device.createBindGroup({
|
|
564
835
|
label: "outline per-frame bind group",
|
|
565
836
|
layout: this.outlinePerFrameBindGroupLayout,
|
|
566
|
-
entries: [
|
|
567
|
-
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
568
|
-
],
|
|
837
|
+
entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
|
|
569
838
|
});
|
|
570
839
|
const outlineShaderModule = this.device.createShaderModule({
|
|
571
840
|
label: "outline shaders",
|
|
@@ -621,17 +890,36 @@ export class Engine {
|
|
|
621
890
|
}
|
|
622
891
|
let worldPos = skinnedPos.xyz;
|
|
623
892
|
let worldNormal = normalize(skinnedNrm);
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
893
|
+
|
|
894
|
+
// Screen-space outline extrusion — MMD-style pixel-stable edge line.
|
|
895
|
+
// 1. Project position and normal-as-direction to clip space.
|
|
896
|
+
// 2. Normalize the 2D clip-space normal, aspect-compensated so "one pixel horizontally"
|
|
897
|
+
// matches "one pixel vertically" (otherwise wide viewports squash the outline in X).
|
|
898
|
+
// 3. Offset clip-space xy by (normal * edgeSize * edgeScale), then multiply by w
|
|
899
|
+
// so the perspective divide cancels out → offset stays constant in NDC regardless
|
|
900
|
+
// of depth, matching how MMD / babylon-mmd style outlines look identical when zooming.
|
|
901
|
+
// 4. edgeScale is in NDC-y units per PMX edgeSize. ≈ 0.006 gives ~3px at 1080p; it's
|
|
902
|
+
// tied to viewport HEIGHT so resizing the window keeps pixel thickness stable.
|
|
903
|
+
let viewProj = camera.projection * camera.view;
|
|
904
|
+
let clipPos = viewProj * vec4f(worldPos, 1.0);
|
|
905
|
+
let clipNormal = (viewProj * vec4f(worldNormal, 0.0)).xy;
|
|
906
|
+
// projection is column-major: proj[0][0] = 1/(aspect·tan(fov/2)), proj[1][1] = 1/tan(fov/2).
|
|
907
|
+
// Ratio proj[1][1]/proj[0][0] recovers the viewport aspect (width/height).
|
|
908
|
+
let aspect = camera.projection[1][1] / camera.projection[0][0];
|
|
909
|
+
let pixelDir = normalize(vec2f(clipNormal.x * aspect, clipNormal.y));
|
|
910
|
+
let ndcDir = vec2f(pixelDir.x / aspect, pixelDir.y);
|
|
911
|
+
let edgeScale = 0.0016;
|
|
912
|
+
let offset = ndcDir * material.edgeSize * edgeScale * clipPos.w;
|
|
913
|
+
output.position = vec4f(clipPos.xy + offset, clipPos.z, clipPos.w);
|
|
630
914
|
return output;
|
|
631
915
|
}
|
|
632
916
|
|
|
633
|
-
|
|
634
|
-
|
|
917
|
+
struct FSOut { @location(0) color: vec4f, @location(1) mask: f32 };
|
|
918
|
+
@fragment fn fs() -> FSOut {
|
|
919
|
+
var out: FSOut;
|
|
920
|
+
out.color = material.edgeColor;
|
|
921
|
+
out.mask = 1.0;
|
|
922
|
+
return out;
|
|
635
923
|
}
|
|
636
924
|
`,
|
|
637
925
|
});
|
|
@@ -640,7 +928,7 @@ export class Engine {
|
|
|
640
928
|
layout: outlinePipelineLayout,
|
|
641
929
|
shaderModule: outlineShaderModule,
|
|
642
930
|
vertexBuffers: outlineVertexBuffers,
|
|
643
|
-
|
|
931
|
+
fragmentTargets: sceneTargets,
|
|
644
932
|
cullMode: "back",
|
|
645
933
|
depthStencil: {
|
|
646
934
|
format: "depth24plus-stencil8",
|
|
@@ -649,6 +937,282 @@ export class Engine {
|
|
|
649
937
|
depthCompare: "less-equal",
|
|
650
938
|
},
|
|
651
939
|
});
|
|
940
|
+
// ─── Bloom (EEVEE 3.6 pyramid): blit(Karis prefilter) → 13-tap downsamples → 9-tap tent upsamples ───
|
|
941
|
+
// Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl.
|
|
942
|
+
// Firefly suppression lives in the blit (Karis luminance-weighted 4-tap average). A single-pass
|
|
943
|
+
// Gaussian cannot reproduce this — hot pixels dominate and produce the sparkle halo.
|
|
944
|
+
this.bloomSampler = this.device.createSampler({
|
|
945
|
+
label: "bloom sampler",
|
|
946
|
+
magFilter: "linear",
|
|
947
|
+
minFilter: "linear",
|
|
948
|
+
addressModeU: "clamp-to-edge",
|
|
949
|
+
addressModeV: "clamp-to-edge",
|
|
950
|
+
});
|
|
951
|
+
this.bloomBlitUniformBuffer = this.device.createBuffer({
|
|
952
|
+
label: "bloom blit uniforms",
|
|
953
|
+
size: 16,
|
|
954
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
955
|
+
});
|
|
956
|
+
this.bloomUpsampleUniformBuffer = this.device.createBuffer({
|
|
957
|
+
label: "bloom upsample uniforms",
|
|
958
|
+
size: 16,
|
|
959
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
960
|
+
});
|
|
961
|
+
this.bloomBlitBindGroupLayout = this.device.createBindGroupLayout({
|
|
962
|
+
label: "bloom blit layout",
|
|
963
|
+
entries: [
|
|
964
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
|
|
965
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
966
|
+
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
|
|
967
|
+
],
|
|
968
|
+
});
|
|
969
|
+
this.bloomDownsampleBindGroupLayout = this.device.createBindGroupLayout({
|
|
970
|
+
label: "bloom downsample layout",
|
|
971
|
+
entries: [
|
|
972
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
973
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
974
|
+
],
|
|
975
|
+
});
|
|
976
|
+
this.bloomUpsampleBindGroupLayout = this.device.createBindGroupLayout({
|
|
977
|
+
label: "bloom upsample layout",
|
|
978
|
+
entries: [
|
|
979
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // coarser-mip accumulator
|
|
980
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // matching downsample mip (base add)
|
|
981
|
+
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
982
|
+
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
983
|
+
],
|
|
984
|
+
});
|
|
985
|
+
const bloomFullscreenVs = /* wgsl */ `
|
|
986
|
+
@vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
|
987
|
+
let x = f32((vi & 1u) << 2u) - 1.0;
|
|
988
|
+
let y = f32((vi & 2u) << 1u) - 1.0;
|
|
989
|
+
return vec4f(x, y, 0.0, 1.0);
|
|
990
|
+
}
|
|
991
|
+
`;
|
|
992
|
+
// Blit: full-res HDR → half-res. Karis 4-tap firefly average + EEVEE quadratic knee threshold + clamp.
|
|
993
|
+
const bloomBlitShader = this.device.createShaderModule({
|
|
994
|
+
label: "bloom blit (Karis prefilter)",
|
|
995
|
+
code: `${bloomFullscreenVs}
|
|
996
|
+
@group(0) @binding(0) var hdrTex: texture_2d<f32>;
|
|
997
|
+
@group(0) @binding(1) var<uniform> prefilter: vec4<f32>; // threshold, knee, clamp, _unused
|
|
998
|
+
@group(0) @binding(2) var maskTex: texture_2d<f32>;
|
|
999
|
+
|
|
1000
|
+
fn luminance(c: vec3f) -> f32 {
|
|
1001
|
+
return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));
|
|
1002
|
+
}
|
|
1003
|
+
fn fetch(c: vec2<i32>, clampV: f32) -> vec3f {
|
|
1004
|
+
let d = vec2<i32>(textureDimensions(hdrTex));
|
|
1005
|
+
let cc = clamp(c, vec2<i32>(0), d - vec2<i32>(1));
|
|
1006
|
+
let s = textureLoad(hdrTex, cc, 0);
|
|
1007
|
+
// Scene pass uses src-alpha blend with clear alpha 0 → premultiplied. Unpremultiply.
|
|
1008
|
+
let rgb = max(s.rgb / max(s.a, 1e-6), vec3f(0.0));
|
|
1009
|
+
// Bloom mask: MRT r8unorm written by material shaders (1.0 = bloom, 0.0 = skip).
|
|
1010
|
+
let mask = textureLoad(maskTex, cc, 0).r;
|
|
1011
|
+
let masked = rgb * mask;
|
|
1012
|
+
// Blender: clamp each tap BEFORE Karis average (eevee_bloom: color = min(clampIntensity, color)).
|
|
1013
|
+
return select(masked, min(masked, vec3f(clampV)), clampV > 0.0);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
|
|
1017
|
+
let dst = vec2<i32>(p.xy - vec2f(0.5));
|
|
1018
|
+
let base = dst * 2;
|
|
1019
|
+
let clampV = prefilter.z;
|
|
1020
|
+
let a = fetch(base + vec2<i32>(0, 0), clampV);
|
|
1021
|
+
let b = fetch(base + vec2<i32>(1, 0), clampV);
|
|
1022
|
+
let c = fetch(base + vec2<i32>(0, 1), clampV);
|
|
1023
|
+
let d = fetch(base + vec2<i32>(1, 1), clampV);
|
|
1024
|
+
// Karis partial average: weight each tap by 1/(1+luma) — suppresses fireflies.
|
|
1025
|
+
let wa = 1.0 / (1.0 + luminance(a));
|
|
1026
|
+
let wb = 1.0 / (1.0 + luminance(b));
|
|
1027
|
+
let wc = 1.0 / (1.0 + luminance(c));
|
|
1028
|
+
let wd = 1.0 / (1.0 + luminance(d));
|
|
1029
|
+
let avg = (a * wa + b * wb + c * wc + d * wd) / max(wa + wb + wc + wd, 1e-6);
|
|
1030
|
+
// EEVEE quadratic threshold (brightness = max-channel, then soft-knee curve).
|
|
1031
|
+
let bright = max(avg.r, max(avg.g, avg.b));
|
|
1032
|
+
let soft = clamp(bright - prefilter.x + prefilter.y, 0.0, 2.0 * prefilter.y);
|
|
1033
|
+
let q = (soft * soft) / (4.0 * max(prefilter.y, 1e-4) + 1e-6);
|
|
1034
|
+
let contrib = max(q, bright - prefilter.x) / max(bright, 1e-4);
|
|
1035
|
+
return vec4f(max(avg * contrib, vec3f(0.0)), 1.0);
|
|
1036
|
+
}
|
|
1037
|
+
`,
|
|
1038
|
+
});
|
|
1039
|
+
// Downsample: Jimenez/COD 13-tap dual-box — 5 weighted 2×2 averages, rejects nyquist ringing.
|
|
1040
|
+
const bloomDownsampleShader = this.device.createShaderModule({
|
|
1041
|
+
label: "bloom downsample 13-tap",
|
|
1042
|
+
code: `${bloomFullscreenVs}
|
|
1043
|
+
@group(0) @binding(0) var srcTex: texture_2d<f32>;
|
|
1044
|
+
@group(0) @binding(1) var srcSamp: sampler;
|
|
1045
|
+
|
|
1046
|
+
fn samp(uv: vec2f, off: vec2f) -> vec3f {
|
|
1047
|
+
return textureSampleLevel(srcTex, srcSamp, uv + off, 0.0).rgb;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
|
|
1051
|
+
let srcDims = vec2f(textureDimensions(srcTex));
|
|
1052
|
+
let t = 1.0 / srcDims;
|
|
1053
|
+
// fragCoord.xy reports pixel centers (e.g. 0.5,0.5 for first pixel) — divide by dst dims directly.
|
|
1054
|
+
let dstDims = srcDims * 0.5;
|
|
1055
|
+
let uv = p.xy / max(dstDims, vec2f(1.0));
|
|
1056
|
+
let A = samp(uv, t * vec2f(-2.0, -2.0));
|
|
1057
|
+
let B = samp(uv, t * vec2f( 0.0, -2.0));
|
|
1058
|
+
let C = samp(uv, t * vec2f( 2.0, -2.0));
|
|
1059
|
+
let D = samp(uv, t * vec2f(-1.0, -1.0));
|
|
1060
|
+
let E = samp(uv, t * vec2f( 1.0, -1.0));
|
|
1061
|
+
let F = samp(uv, t * vec2f(-2.0, 0.0));
|
|
1062
|
+
let G = samp(uv, t * vec2f( 0.0, 0.0));
|
|
1063
|
+
let H = samp(uv, t * vec2f( 2.0, 0.0));
|
|
1064
|
+
let I = samp(uv, t * vec2f(-1.0, 1.0));
|
|
1065
|
+
let J = samp(uv, t * vec2f( 1.0, 1.0));
|
|
1066
|
+
let K = samp(uv, t * vec2f(-2.0, 2.0));
|
|
1067
|
+
let L = samp(uv, t * vec2f( 0.0, 2.0));
|
|
1068
|
+
let M = samp(uv, t * vec2f( 2.0, 2.0));
|
|
1069
|
+
var o = (D + E + I + J) * (0.5 / 4.0);
|
|
1070
|
+
o = o + (A + B + G + F) * (0.125 / 4.0);
|
|
1071
|
+
o = o + (B + C + H + G) * (0.125 / 4.0);
|
|
1072
|
+
o = o + (F + G + L + K) * (0.125 / 4.0);
|
|
1073
|
+
o = o + (G + H + M + L) * (0.125 / 4.0);
|
|
1074
|
+
return vec4f(o, 1.0);
|
|
1075
|
+
}
|
|
1076
|
+
`,
|
|
1077
|
+
});
|
|
1078
|
+
// Upsample: 9-tap tent, progressively added to matching downsample mip. Blender radius = sample scale.
|
|
1079
|
+
const bloomUpsampleShader = this.device.createShaderModule({
|
|
1080
|
+
label: "bloom upsample 9-tap tent",
|
|
1081
|
+
code: `${bloomFullscreenVs}
|
|
1082
|
+
@group(0) @binding(0) var srcTex: texture_2d<f32>; // coarser accumulator
|
|
1083
|
+
@group(0) @binding(1) var baseTex: texture_2d<f32>; // matching downsample mip
|
|
1084
|
+
@group(0) @binding(2) var srcSamp: sampler;
|
|
1085
|
+
@group(0) @binding(3) var<uniform> upU: vec4<f32>; // sampleScale, _, _, _
|
|
1086
|
+
|
|
1087
|
+
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
|
|
1088
|
+
let srcDims = vec2f(textureDimensions(srcTex));
|
|
1089
|
+
let baseDims = vec2f(textureDimensions(baseTex));
|
|
1090
|
+
let uv = p.xy / max(baseDims, vec2f(1.0));
|
|
1091
|
+
let t = upU.x / srcDims;
|
|
1092
|
+
var o = textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, -1.0), 0.0).rgb * 1.0;
|
|
1093
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, -1.0), 0.0).rgb * 2.0;
|
|
1094
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, -1.0), 0.0).rgb * 1.0;
|
|
1095
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 0.0), 0.0).rgb * 2.0;
|
|
1096
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 0.0), 0.0).rgb * 4.0;
|
|
1097
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 0.0), 0.0).rgb * 2.0;
|
|
1098
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 1.0), 0.0).rgb * 1.0;
|
|
1099
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 1.0), 0.0).rgb * 2.0;
|
|
1100
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 1.0), 0.0).rgb * 1.0;
|
|
1101
|
+
o = o * (1.0 / 16.0);
|
|
1102
|
+
let base = textureSampleLevel(baseTex, srcSamp, uv, 0.0).rgb;
|
|
1103
|
+
return vec4f(o + base, 1.0);
|
|
1104
|
+
}
|
|
1105
|
+
`,
|
|
1106
|
+
});
|
|
1107
|
+
const bloomBlitLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomBlitBindGroupLayout] });
|
|
1108
|
+
const bloomDownLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomDownsampleBindGroupLayout] });
|
|
1109
|
+
const bloomUpLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomUpsampleBindGroupLayout] });
|
|
1110
|
+
this.bloomBlitPipeline = this.device.createRenderPipeline({
|
|
1111
|
+
label: "bloom blit pipeline",
|
|
1112
|
+
layout: bloomBlitLayout,
|
|
1113
|
+
vertex: { module: bloomBlitShader, entryPoint: "vs" },
|
|
1114
|
+
fragment: { module: bloomBlitShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
|
|
1115
|
+
primitive: { topology: "triangle-list" },
|
|
1116
|
+
});
|
|
1117
|
+
this.bloomDownsamplePipeline = this.device.createRenderPipeline({
|
|
1118
|
+
label: "bloom downsample pipeline",
|
|
1119
|
+
layout: bloomDownLayout,
|
|
1120
|
+
vertex: { module: bloomDownsampleShader, entryPoint: "vs" },
|
|
1121
|
+
fragment: { module: bloomDownsampleShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
|
|
1122
|
+
primitive: { topology: "triangle-list" },
|
|
1123
|
+
});
|
|
1124
|
+
this.bloomUpsamplePipeline = this.device.createRenderPipeline({
|
|
1125
|
+
label: "bloom upsample pipeline",
|
|
1126
|
+
layout: bloomUpLayout,
|
|
1127
|
+
vertex: { module: bloomUpsampleShader, entryPoint: "vs" },
|
|
1128
|
+
fragment: { module: bloomUpsampleShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
|
|
1129
|
+
primitive: { topology: "triangle-list" },
|
|
1130
|
+
});
|
|
1131
|
+
// ─── Composite: HDR + bloom → Filmic → swapchain (premultiplied) ───
|
|
1132
|
+
// Bloom color/intensity applied HERE (pyramid is pure energy; tint belongs to the combine step,
|
|
1133
|
+
// mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
|
|
1134
|
+
this.compositeUniformBuffer = this.device.createBuffer({
|
|
1135
|
+
label: "composite view uniforms",
|
|
1136
|
+
size: 32,
|
|
1137
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1138
|
+
});
|
|
1139
|
+
this.compositeBindGroupLayout = this.device.createBindGroupLayout({
|
|
1140
|
+
label: "composite bind group layout",
|
|
1141
|
+
entries: [
|
|
1142
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
|
|
1143
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
1144
|
+
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
1145
|
+
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
1146
|
+
],
|
|
1147
|
+
});
|
|
1148
|
+
const compositeShader = this.device.createShaderModule({
|
|
1149
|
+
label: "composite shader",
|
|
1150
|
+
code: /* wgsl */ `
|
|
1151
|
+
@group(0) @binding(0) var hdrTex: texture_2d<f32>;
|
|
1152
|
+
@group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)
|
|
1153
|
+
@group(0) @binding(2) var bloomSamp: sampler;
|
|
1154
|
+
@group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 2>;
|
|
1155
|
+
// viewU[0] = (exposure, gamma, _, _); viewU[1] = (tint.rgb, intensity)
|
|
1156
|
+
|
|
1157
|
+
fn filmic(x: f32) -> f32 {
|
|
1158
|
+
var lut = array<f32, 14>(
|
|
1159
|
+
0.0067, 0.0141, 0.0272, 0.0499, 0.0885, 0.1512, 0.2462,
|
|
1160
|
+
0.3753, 0.5273, 0.6776, 0.8031, 0.8929, 0.9495, 0.9814
|
|
1161
|
+
);
|
|
1162
|
+
let t = clamp(log2(max(x, 1e-10)) + 10.0, 0.0, 13.0);
|
|
1163
|
+
let i = u32(t);
|
|
1164
|
+
let j = min(i + 1u, 13u);
|
|
1165
|
+
return mix(lut[i], lut[j], t - f32(i));
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
@vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
|
1169
|
+
let x = f32((vi & 1u) << 2u) - 1.0;
|
|
1170
|
+
let y = f32((vi & 2u) << 1u) - 1.0;
|
|
1171
|
+
return vec4f(x, y, 0.0, 1.0);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
@fragment fn fs(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f {
|
|
1175
|
+
let hdr = textureLoad(hdrTex, vec2<i32>(fragCoord.xy), 0);
|
|
1176
|
+
let a = max(hdr.a, 1e-6);
|
|
1177
|
+
let straight = hdr.rgb / a;
|
|
1178
|
+
let fullSz = vec2f(textureDimensions(hdrTex));
|
|
1179
|
+
let bloomSz = vec2f(textureDimensions(bloomTex));
|
|
1180
|
+
// Bloom is at half-res (pyramid mip 0). Sampler interpolates back to full-res UVs.
|
|
1181
|
+
let bloomUv = (fragCoord.xy + vec2f(0.5)) / max(fullSz, vec2f(1.0));
|
|
1182
|
+
let tint = viewU[1].xyz;
|
|
1183
|
+
let intensity = viewU[1].w;
|
|
1184
|
+
let bloom = textureSampleLevel(bloomTex, bloomSamp, bloomUv, 0.0).rgb * tint * intensity;
|
|
1185
|
+
let combined = straight + bloom;
|
|
1186
|
+
let exposed = combined * exp2(viewU[0].x);
|
|
1187
|
+
let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));
|
|
1188
|
+
let g = max(viewU[0].y, 1e-4);
|
|
1189
|
+
let disp = pow(max(tm, vec3f(0.0)), vec3f(1.0 / g));
|
|
1190
|
+
return vec4f(disp * hdr.a, hdr.a);
|
|
1191
|
+
}
|
|
1192
|
+
`,
|
|
1193
|
+
});
|
|
1194
|
+
this.compositePipeline = this.device.createRenderPipeline({
|
|
1195
|
+
label: "composite pipeline",
|
|
1196
|
+
layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.compositeBindGroupLayout] }),
|
|
1197
|
+
vertex: { module: compositeShader, entryPoint: "vs" },
|
|
1198
|
+
fragment: {
|
|
1199
|
+
module: compositeShader,
|
|
1200
|
+
entryPoint: "fs",
|
|
1201
|
+
targets: [{ format: this.presentationFormat }],
|
|
1202
|
+
},
|
|
1203
|
+
primitive: { topology: "triangle-list" },
|
|
1204
|
+
});
|
|
1205
|
+
this.bloomPassDescriptor = {
|
|
1206
|
+
label: "bloom pass",
|
|
1207
|
+
colorAttachments: [
|
|
1208
|
+
{
|
|
1209
|
+
view: undefined,
|
|
1210
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
1211
|
+
loadOp: "clear",
|
|
1212
|
+
storeOp: "store",
|
|
1213
|
+
},
|
|
1214
|
+
],
|
|
1215
|
+
};
|
|
652
1216
|
// GPU picking: encode (modelIndex, materialIndex) as color
|
|
653
1217
|
const pickShaderModule = this.device.createShaderModule({
|
|
654
1218
|
label: "pick shader",
|
|
@@ -693,32 +1257,28 @@ export class Engine {
|
|
|
693
1257
|
});
|
|
694
1258
|
this.pickPerFrameBindGroupLayout = this.device.createBindGroupLayout({
|
|
695
1259
|
label: "pick per-frame layout",
|
|
696
|
-
entries: [
|
|
697
|
-
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
|
|
698
|
-
],
|
|
1260
|
+
entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } }],
|
|
699
1261
|
});
|
|
700
1262
|
this.pickPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
|
|
701
1263
|
label: "pick per-instance layout",
|
|
702
|
-
entries: [
|
|
703
|
-
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
704
|
-
],
|
|
1264
|
+
entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }],
|
|
705
1265
|
});
|
|
706
1266
|
this.pickPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
|
|
707
1267
|
label: "pick per-material layout",
|
|
708
|
-
entries: [
|
|
709
|
-
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
710
|
-
],
|
|
1268
|
+
entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }],
|
|
711
1269
|
});
|
|
712
1270
|
const pickPipelineLayout = this.device.createPipelineLayout({
|
|
713
1271
|
label: "pick pipeline layout",
|
|
714
|
-
bindGroupLayouts: [
|
|
1272
|
+
bindGroupLayouts: [
|
|
1273
|
+
this.pickPerFrameBindGroupLayout,
|
|
1274
|
+
this.pickPerInstanceBindGroupLayout,
|
|
1275
|
+
this.pickPerMaterialBindGroupLayout,
|
|
1276
|
+
],
|
|
715
1277
|
});
|
|
716
1278
|
this.pickPerFrameBindGroup = this.device.createBindGroup({
|
|
717
1279
|
label: "pick per-frame bind group",
|
|
718
1280
|
layout: this.pickPerFrameBindGroupLayout,
|
|
719
|
-
entries: [
|
|
720
|
-
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
721
|
-
],
|
|
1281
|
+
entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
|
|
722
1282
|
});
|
|
723
1283
|
this.pickPipeline = this.device.createRenderPipeline({
|
|
724
1284
|
label: "pick pipeline",
|
|
@@ -762,12 +1322,63 @@ export class Engine {
|
|
|
762
1322
|
this.canvas.width = width;
|
|
763
1323
|
this.canvas.height = height;
|
|
764
1324
|
this.multisampleTexture = this.device.createTexture({
|
|
765
|
-
label: "multisample render target",
|
|
1325
|
+
label: "multisample HDR render target",
|
|
1326
|
+
size: [width, height],
|
|
1327
|
+
sampleCount: Engine.MULTISAMPLE_COUNT,
|
|
1328
|
+
format: Engine.HDR_FORMAT,
|
|
1329
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
|
1330
|
+
});
|
|
1331
|
+
this.hdrResolveTexture = this.device.createTexture({
|
|
1332
|
+
label: "HDR resolve target",
|
|
1333
|
+
size: [width, height],
|
|
1334
|
+
format: Engine.HDR_FORMAT,
|
|
1335
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1336
|
+
});
|
|
1337
|
+
// Bloom-mask MRT attachments — same dims + MSAA as HDR so they share the render pass.
|
|
1338
|
+
// MS buffer gets resolved into maskResolveTexture, which the bloom blit pass samples.
|
|
1339
|
+
this.multisampleMaskTexture = this.device.createTexture({
|
|
1340
|
+
label: "multisample bloom mask",
|
|
766
1341
|
size: [width, height],
|
|
767
1342
|
sampleCount: Engine.MULTISAMPLE_COUNT,
|
|
768
|
-
format:
|
|
1343
|
+
format: Engine.BLOOM_MASK_FORMAT,
|
|
769
1344
|
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
|
770
1345
|
});
|
|
1346
|
+
this.maskResolveTexture = this.device.createTexture({
|
|
1347
|
+
label: "bloom mask resolve",
|
|
1348
|
+
size: [width, height],
|
|
1349
|
+
format: Engine.BLOOM_MASK_FORMAT,
|
|
1350
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1351
|
+
});
|
|
1352
|
+
this.maskResolveView = this.maskResolveTexture.createView();
|
|
1353
|
+
// Bloom pyramid: mip 0 is half-res, each subsequent mip halves again.
|
|
1354
|
+
// Mip count chosen so the coarsest mip is ≥4 px on the short side, capped at BLOOM_MAX_LEVELS.
|
|
1355
|
+
const bw = Math.max(1, Math.floor(width / 2));
|
|
1356
|
+
const bh = Math.max(1, Math.floor(height / 2));
|
|
1357
|
+
const shortSide = Math.max(1, Math.min(bw, bh));
|
|
1358
|
+
this.bloomMipCount = Math.max(1, Math.min(Engine.BLOOM_MAX_LEVELS, Math.floor(Math.log2(shortSide)) - 1));
|
|
1359
|
+
this.bloomDownTexture = this.device.createTexture({
|
|
1360
|
+
label: "bloom down pyramid",
|
|
1361
|
+
size: [bw, bh],
|
|
1362
|
+
mipLevelCount: this.bloomMipCount,
|
|
1363
|
+
format: Engine.HDR_FORMAT,
|
|
1364
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1365
|
+
});
|
|
1366
|
+
this.bloomUpTexture = this.device.createTexture({
|
|
1367
|
+
label: "bloom up pyramid",
|
|
1368
|
+
size: [bw, bh],
|
|
1369
|
+
mipLevelCount: Math.max(1, this.bloomMipCount - 1),
|
|
1370
|
+
format: Engine.HDR_FORMAT,
|
|
1371
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1372
|
+
});
|
|
1373
|
+
this.bloomDownMipViews = [];
|
|
1374
|
+
for (let i = 0; i < this.bloomMipCount; i++) {
|
|
1375
|
+
this.bloomDownMipViews.push(this.bloomDownTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }));
|
|
1376
|
+
}
|
|
1377
|
+
this.bloomUpMipViews = [];
|
|
1378
|
+
const upLevels = Math.max(1, this.bloomMipCount - 1);
|
|
1379
|
+
for (let i = 0; i < upLevels; i++) {
|
|
1380
|
+
this.bloomUpMipViews.push(this.bloomUpTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }));
|
|
1381
|
+
}
|
|
771
1382
|
this.depthTexture = this.device.createTexture({
|
|
772
1383
|
label: "depth texture",
|
|
773
1384
|
size: [width, height],
|
|
@@ -778,14 +1389,21 @@ export class Engine {
|
|
|
778
1389
|
const depthTextureView = this.depthTexture.createView();
|
|
779
1390
|
const colorAttachment = {
|
|
780
1391
|
view: this.multisampleTexture.createView(),
|
|
781
|
-
resolveTarget: this.
|
|
1392
|
+
resolveTarget: this.hdrResolveTexture.createView(),
|
|
1393
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
1394
|
+
loadOp: "clear",
|
|
1395
|
+
storeOp: "store",
|
|
1396
|
+
};
|
|
1397
|
+
const maskAttachment = {
|
|
1398
|
+
view: this.multisampleMaskTexture.createView(),
|
|
1399
|
+
resolveTarget: this.maskResolveView,
|
|
782
1400
|
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
783
1401
|
loadOp: "clear",
|
|
784
1402
|
storeOp: "store",
|
|
785
1403
|
};
|
|
786
1404
|
this.renderPassDescriptor = {
|
|
787
1405
|
label: "renderPass",
|
|
788
|
-
colorAttachments: [colorAttachment],
|
|
1406
|
+
colorAttachments: [colorAttachment, maskAttachment],
|
|
789
1407
|
depthStencilAttachment: {
|
|
790
1408
|
view: depthTextureView,
|
|
791
1409
|
depthClearValue: 1.0,
|
|
@@ -796,6 +1414,73 @@ export class Engine {
|
|
|
796
1414
|
stencilStoreOp: "discard",
|
|
797
1415
|
},
|
|
798
1416
|
};
|
|
1417
|
+
// Composite pass descriptor (color attachment view patched per-frame to current swapchain).
|
|
1418
|
+
this.compositePassDescriptor = {
|
|
1419
|
+
label: "composite pass",
|
|
1420
|
+
colorAttachments: [
|
|
1421
|
+
{
|
|
1422
|
+
view: undefined,
|
|
1423
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
1424
|
+
loadOp: "clear",
|
|
1425
|
+
storeOp: "store",
|
|
1426
|
+
},
|
|
1427
|
+
],
|
|
1428
|
+
};
|
|
1429
|
+
this.writeBloomUniforms();
|
|
1430
|
+
if (this.compositeBindGroupLayout && this.bloomBlitBindGroupLayout) {
|
|
1431
|
+
// Blit: reads HDR resolve texture (full-res), writes bloomDown mip 0.
|
|
1432
|
+
this.bloomBlitBindGroup = this.device.createBindGroup({
|
|
1433
|
+
label: "bloom blit bind group",
|
|
1434
|
+
layout: this.bloomBlitBindGroupLayout,
|
|
1435
|
+
entries: [
|
|
1436
|
+
{ binding: 0, resource: this.hdrResolveTexture.createView() },
|
|
1437
|
+
{ binding: 1, resource: { buffer: this.bloomBlitUniformBuffer } },
|
|
1438
|
+
{ binding: 2, resource: this.maskResolveView },
|
|
1439
|
+
],
|
|
1440
|
+
});
|
|
1441
|
+
// Downsample[i] reads bloomDown mip (i-1), writes bloomDown mip i. i ∈ [1..N-1].
|
|
1442
|
+
this.bloomDownsampleBindGroups = [];
|
|
1443
|
+
for (let i = 1; i < this.bloomMipCount; i++) {
|
|
1444
|
+
this.bloomDownsampleBindGroups.push(this.device.createBindGroup({
|
|
1445
|
+
label: `bloom downsample ${i}`,
|
|
1446
|
+
layout: this.bloomDownsampleBindGroupLayout,
|
|
1447
|
+
entries: [
|
|
1448
|
+
{ binding: 0, resource: this.bloomDownMipViews[i - 1] },
|
|
1449
|
+
{ binding: 1, resource: this.bloomSampler },
|
|
1450
|
+
],
|
|
1451
|
+
}));
|
|
1452
|
+
}
|
|
1453
|
+
// Upsample[i] writes bloomUp mip i. Coarsest step reads bloomDown[N-1] (no prior up yet);
|
|
1454
|
+
// subsequent steps read bloomUp[i+1]. Both read bloomDown[i] as the base (additive combine).
|
|
1455
|
+
this.bloomUpsampleBindGroups = [];
|
|
1456
|
+
const topIdx = this.bloomMipCount - 2;
|
|
1457
|
+
for (let i = topIdx; i >= 0; i--) {
|
|
1458
|
+
const srcView = i === topIdx ? this.bloomDownMipViews[this.bloomMipCount - 1] : this.bloomUpMipViews[i + 1];
|
|
1459
|
+
this.bloomUpsampleBindGroups.push(this.device.createBindGroup({
|
|
1460
|
+
label: `bloom upsample ${i}`,
|
|
1461
|
+
layout: this.bloomUpsampleBindGroupLayout,
|
|
1462
|
+
entries: [
|
|
1463
|
+
{ binding: 0, resource: srcView },
|
|
1464
|
+
{ binding: 1, resource: this.bloomDownMipViews[i] },
|
|
1465
|
+
{ binding: 2, resource: this.bloomSampler },
|
|
1466
|
+
{ binding: 3, resource: { buffer: this.bloomUpsampleUniformBuffer } },
|
|
1467
|
+
],
|
|
1468
|
+
}));
|
|
1469
|
+
}
|
|
1470
|
+
// Composite reads bloomUp mip 0 (full pyramid collapsed); fallback to bloomDown mip 0 if no upsample level.
|
|
1471
|
+
const compositeBloomView = this.bloomMipCount > 1 ? this.bloomUpMipViews[0] : this.bloomDownMipViews[0];
|
|
1472
|
+
this.compositeBindGroup = this.device.createBindGroup({
|
|
1473
|
+
label: "composite bind group",
|
|
1474
|
+
layout: this.compositeBindGroupLayout,
|
|
1475
|
+
entries: [
|
|
1476
|
+
{ binding: 0, resource: this.hdrResolveTexture.createView() },
|
|
1477
|
+
{ binding: 1, resource: compositeBloomView },
|
|
1478
|
+
{ binding: 2, resource: this.bloomSampler },
|
|
1479
|
+
{ binding: 3, resource: { buffer: this.compositeUniformBuffer } },
|
|
1480
|
+
],
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
this.writeCompositeViewUniforms();
|
|
799
1484
|
this.camera.aspect = width / height;
|
|
800
1485
|
if (this.onRaycast) {
|
|
801
1486
|
this.pickTexture = this.device.createTexture({
|
|
@@ -820,7 +1505,7 @@ export class Engine {
|
|
|
820
1505
|
size: 40 * 4,
|
|
821
1506
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
822
1507
|
});
|
|
823
|
-
this.camera = new Camera(Math.PI, Math.PI / 2.5, this.
|
|
1508
|
+
this.camera = new Camera(Math.PI, Math.PI / 2.5, this.cameraConfig.distance, this.cameraConfig.target, this.cameraConfig.fov);
|
|
824
1509
|
this.camera.aspect = this.canvas.width / this.canvas.height;
|
|
825
1510
|
this.camera.attachControl(this.canvas);
|
|
826
1511
|
}
|
|
@@ -854,58 +1539,100 @@ export class Engine {
|
|
|
854
1539
|
this.cameraTargetOffset.y = offset?.y ?? 0;
|
|
855
1540
|
this.cameraTargetOffset.z = offset?.z ?? 0;
|
|
856
1541
|
}
|
|
857
|
-
getCameraDistance() {
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
1542
|
+
getCameraDistance() {
|
|
1543
|
+
return this.camera.radius;
|
|
1544
|
+
}
|
|
1545
|
+
setCameraDistance(d) {
|
|
1546
|
+
this.camera.radius = d;
|
|
1547
|
+
}
|
|
1548
|
+
getCameraAlpha() {
|
|
1549
|
+
return this.camera.alpha;
|
|
1550
|
+
}
|
|
1551
|
+
setCameraAlpha(a) {
|
|
1552
|
+
this.camera.alpha = a;
|
|
1553
|
+
}
|
|
1554
|
+
getCameraBeta() {
|
|
1555
|
+
return this.camera.beta;
|
|
1556
|
+
}
|
|
1557
|
+
setCameraBeta(b) {
|
|
1558
|
+
this.camera.beta = b;
|
|
1559
|
+
}
|
|
863
1560
|
// Step 5: Create lighting buffers
|
|
864
1561
|
setupLighting() {
|
|
865
1562
|
this.lightUniformBuffer = this.device.createBuffer({
|
|
866
1563
|
label: "light uniforms",
|
|
867
|
-
size: 64 * 4, //
|
|
1564
|
+
size: 64 * 4, // ambientColor vec4f (4) + 4 lights * 2 vec4f each (32) = 36 f32 padded to 64
|
|
868
1565
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
869
1566
|
});
|
|
870
|
-
// Initialize light buffer to zeros
|
|
871
1567
|
this.lightData.fill(0);
|
|
872
1568
|
this.lightCount = 0;
|
|
873
|
-
this.
|
|
874
|
-
this.
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
1569
|
+
this.writeWorld();
|
|
1570
|
+
this.writeSun(0);
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Write world ambient. For a uniform-radiance world, hemispherical irradiance
|
|
1574
|
+
* is E = π·L and a Lambertian BRDF reflects (albedo/π)·E = albedo·L, so the
|
|
1575
|
+
* shader's ambient uniform is just `world.color × world.strength` — no /π.
|
|
1576
|
+
*/
|
|
1577
|
+
writeWorld() {
|
|
1578
|
+
const s = this.world.strength;
|
|
1579
|
+
this.lightData[0] = this.world.color.x * s;
|
|
1580
|
+
this.lightData[1] = this.world.color.y * s;
|
|
1581
|
+
this.lightData[2] = this.world.color.z * s;
|
|
1582
|
+
this.lightData[3] = 0;
|
|
882
1583
|
this.updateLightBuffer();
|
|
883
1584
|
}
|
|
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.
|
|
1585
|
+
/** Write sun lamp into light slot `index` (0..3). Layout mirrors the WGSL struct. */
|
|
1586
|
+
writeSun(index) {
|
|
1587
|
+
if (index < 0 || index >= 4)
|
|
1588
|
+
return;
|
|
1589
|
+
const normalized = this.sun.direction.normalize();
|
|
1590
|
+
const base = 4 + index * 8; // 8 floats per light (direction vec4, color vec4)
|
|
1591
|
+
this.lightData[base] = normalized.x;
|
|
1592
|
+
this.lightData[base + 1] = normalized.y;
|
|
1593
|
+
this.lightData[base + 2] = normalized.z;
|
|
1594
|
+
this.lightData[base + 3] = 0;
|
|
1595
|
+
this.lightData[base + 4] = this.sun.color.x;
|
|
1596
|
+
this.lightData[base + 5] = this.sun.color.y;
|
|
1597
|
+
this.lightData[base + 6] = this.sun.color.z;
|
|
1598
|
+
this.lightData[base + 7] = this.sun.strength;
|
|
1599
|
+
if (index >= this.lightCount)
|
|
1600
|
+
this.lightCount = index + 1;
|
|
898
1601
|
this.updateLightBuffer();
|
|
899
|
-
|
|
1602
|
+
}
|
|
1603
|
+
/** Update the world environment (Blender: World Background). Ambient recomputes immediately. */
|
|
1604
|
+
setWorld(options) {
|
|
1605
|
+
if (options.color)
|
|
1606
|
+
this.world.color = options.color;
|
|
1607
|
+
if (options.strength !== undefined)
|
|
1608
|
+
this.world.strength = options.strength;
|
|
1609
|
+
this.writeWorld();
|
|
1610
|
+
}
|
|
1611
|
+
/** Update the sun lamp (Blender: Light > Sun). Direction change marks shadow VP dirty. */
|
|
1612
|
+
setSun(options) {
|
|
1613
|
+
if (options.color)
|
|
1614
|
+
this.sun.color = options.color;
|
|
1615
|
+
if (options.strength !== undefined)
|
|
1616
|
+
this.sun.strength = options.strength;
|
|
1617
|
+
if (options.direction) {
|
|
1618
|
+
this.sun.direction = options.direction;
|
|
1619
|
+
this.shadowLightVPDirty = true;
|
|
1620
|
+
}
|
|
1621
|
+
this.writeSun(0);
|
|
1622
|
+
}
|
|
1623
|
+
getWorld() {
|
|
1624
|
+
return this.world;
|
|
1625
|
+
}
|
|
1626
|
+
getSun() {
|
|
1627
|
+
return this.sun;
|
|
900
1628
|
}
|
|
901
1629
|
addGround(options) {
|
|
902
1630
|
const opts = {
|
|
903
1631
|
width: 160,
|
|
904
1632
|
height: 160,
|
|
905
|
-
diffuseColor: new Vec3(0.
|
|
1633
|
+
diffuseColor: new Vec3(0.9, 0.1, 1.0),
|
|
906
1634
|
fadeStart: 10.0,
|
|
907
1635
|
fadeEnd: 80.0,
|
|
908
|
-
shadowMapSize: 4096,
|
|
909
1636
|
shadowStrength: 1.0,
|
|
910
1637
|
gridSpacing: 4.2,
|
|
911
1638
|
gridLineWidth: 0.012,
|
|
@@ -923,6 +1650,7 @@ export class Engine {
|
|
|
923
1650
|
firstIndex: 0,
|
|
924
1651
|
bindGroup: this.groundShadowBindGroup,
|
|
925
1652
|
materialName: "Ground",
|
|
1653
|
+
preset: "cloth_rough",
|
|
926
1654
|
};
|
|
927
1655
|
}
|
|
928
1656
|
updateLightBuffer() {
|
|
@@ -1039,6 +1767,15 @@ export class Engine {
|
|
|
1039
1767
|
}
|
|
1040
1768
|
}
|
|
1041
1769
|
}
|
|
1770
|
+
setMaterialPresets(modelName, presets) {
|
|
1771
|
+
const inst = this.modelInstances.get(modelName);
|
|
1772
|
+
if (!inst)
|
|
1773
|
+
return;
|
|
1774
|
+
inst.materialPresets = presets;
|
|
1775
|
+
for (const dc of inst.drawCalls) {
|
|
1776
|
+
dc.preset = resolvePreset(dc.materialName, presets);
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1042
1779
|
setMaterialVisible(modelName, materialName, visible) {
|
|
1043
1780
|
const inst = this.modelInstances.get(modelName);
|
|
1044
1781
|
if (!inst)
|
|
@@ -1147,24 +1884,14 @@ export class Engine {
|
|
|
1147
1884
|
const mainPerInstanceBindGroup = this.device.createBindGroup({
|
|
1148
1885
|
label: `${name}: main per-instance bind group`,
|
|
1149
1886
|
layout: this.mainPerInstanceBindGroupLayout,
|
|
1150
|
-
entries: [
|
|
1151
|
-
{ binding: 0, resource: { buffer: skinMatrixBuffer } },
|
|
1152
|
-
],
|
|
1887
|
+
entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
|
|
1153
1888
|
});
|
|
1154
1889
|
const pickPerInstanceBindGroup = this.device.createBindGroup({
|
|
1155
1890
|
label: `${name}: pick per-instance bind group`,
|
|
1156
1891
|
layout: this.pickPerInstanceBindGroupLayout,
|
|
1157
|
-
entries: [
|
|
1158
|
-
{ binding: 0, resource: { buffer: skinMatrixBuffer } },
|
|
1159
|
-
],
|
|
1892
|
+
entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
|
|
1160
1893
|
});
|
|
1161
|
-
const gpuBuffers = [
|
|
1162
|
-
vertexBuffer,
|
|
1163
|
-
indexBuffer,
|
|
1164
|
-
jointsBuffer,
|
|
1165
|
-
weightsBuffer,
|
|
1166
|
-
skinMatrixBuffer,
|
|
1167
|
-
];
|
|
1894
|
+
const gpuBuffers = [vertexBuffer, indexBuffer, jointsBuffer, weightsBuffer, skinMatrixBuffer];
|
|
1168
1895
|
const inst = {
|
|
1169
1896
|
name,
|
|
1170
1897
|
model,
|
|
@@ -1184,6 +1911,7 @@ export class Engine {
|
|
|
1184
1911
|
pickPerInstanceBindGroup,
|
|
1185
1912
|
pickDrawCalls: [],
|
|
1186
1913
|
hiddenMaterials: new Set(),
|
|
1914
|
+
materialPresets: undefined,
|
|
1187
1915
|
physics,
|
|
1188
1916
|
vertexBufferNeedsUpdate: false,
|
|
1189
1917
|
};
|
|
@@ -1255,15 +1983,8 @@ export class Engine {
|
|
|
1255
1983
|
this.device.queue.writeBuffer(this.groundIndexBuffer, 0, indices);
|
|
1256
1984
|
}
|
|
1257
1985
|
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)
|
|
1986
|
+
const { diffuseColor, fadeStart, fadeEnd, shadowStrength, gridSpacing, gridLineWidth, gridLineOpacity, gridLineColor, noiseStrength, } = opts;
|
|
1987
|
+
// Shadow map is already created in setupPipelines()
|
|
1267
1988
|
const gb = new Float32Array(16);
|
|
1268
1989
|
gb[0] = diffuseColor.x;
|
|
1269
1990
|
gb[1] = diffuseColor.y;
|
|
@@ -1271,7 +1992,7 @@ export class Engine {
|
|
|
1271
1992
|
gb[3] = fadeStart;
|
|
1272
1993
|
gb[4] = fadeEnd;
|
|
1273
1994
|
gb[5] = shadowStrength;
|
|
1274
|
-
gb[6] = 1 /
|
|
1995
|
+
gb[6] = 1 / Engine.SHADOW_MAP_SIZE;
|
|
1275
1996
|
gb[7] = gridSpacing;
|
|
1276
1997
|
gb[8] = gridLineWidth;
|
|
1277
1998
|
gb[9] = gridLineOpacity;
|
|
@@ -1281,7 +2002,10 @@ export class Engine {
|
|
|
1281
2002
|
gb[13] = gridLineColor.y;
|
|
1282
2003
|
gb[14] = gridLineColor.z;
|
|
1283
2004
|
gb[15] = 0;
|
|
1284
|
-
this.groundShadowMaterialBuffer = this.device.createBuffer({
|
|
2005
|
+
this.groundShadowMaterialBuffer = this.device.createBuffer({
|
|
2006
|
+
size: gb.byteLength,
|
|
2007
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
2008
|
+
});
|
|
1285
2009
|
this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 0, gb);
|
|
1286
2010
|
this.groundShadowBindGroup = this.device.createBindGroup({
|
|
1287
2011
|
label: "ground shadow bind",
|
|
@@ -1300,7 +2024,7 @@ export class Engine {
|
|
|
1300
2024
|
if (!this.shadowLightVPDirty)
|
|
1301
2025
|
return;
|
|
1302
2026
|
this.shadowLightVPDirty = false;
|
|
1303
|
-
const dir = new Vec3(this.
|
|
2027
|
+
const dir = new Vec3(this.sun.direction.x, this.sun.direction.y, this.sun.direction.z);
|
|
1304
2028
|
dir.normalize();
|
|
1305
2029
|
const target = new Vec3(0, 11, 0);
|
|
1306
2030
|
const eye = new Vec3(target.x - dir.x * 72, target.y - dir.y * 72, target.z - dir.z * 72);
|
|
@@ -1338,7 +2062,11 @@ export class Engine {
|
|
|
1338
2062
|
throw new Error(`Material "${mat.name}" has no diffuse texture`);
|
|
1339
2063
|
const materialAlpha = mat.diffuse[3];
|
|
1340
2064
|
const isTransparent = materialAlpha < 1.0 - 0.001;
|
|
1341
|
-
const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, materialAlpha, [
|
|
2065
|
+
const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, materialAlpha, [
|
|
2066
|
+
mat.diffuse[0],
|
|
2067
|
+
mat.diffuse[1],
|
|
2068
|
+
mat.diffuse[2],
|
|
2069
|
+
]);
|
|
1342
2070
|
inst.gpuBuffers.push(materialUniformBuffer);
|
|
1343
2071
|
const textureView = diffuseTexture.createView();
|
|
1344
2072
|
const bindGroup = this.device.createBindGroup({
|
|
@@ -1350,23 +2078,42 @@ export class Engine {
|
|
|
1350
2078
|
],
|
|
1351
2079
|
});
|
|
1352
2080
|
const type = isTransparent ? "transparent" : "opaque";
|
|
1353
|
-
|
|
2081
|
+
const preset = resolvePreset(mat.name, inst.materialPresets);
|
|
2082
|
+
inst.drawCalls.push({
|
|
2083
|
+
type,
|
|
2084
|
+
count: indexCount,
|
|
2085
|
+
firstIndex: currentIndexOffset,
|
|
2086
|
+
bindGroup,
|
|
2087
|
+
materialName: mat.name,
|
|
2088
|
+
preset,
|
|
2089
|
+
});
|
|
1354
2090
|
if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
|
|
1355
2091
|
const materialUniformData = new Float32Array([
|
|
1356
|
-
mat.edgeColor[0],
|
|
1357
|
-
mat.
|
|
2092
|
+
mat.edgeColor[0],
|
|
2093
|
+
mat.edgeColor[1],
|
|
2094
|
+
mat.edgeColor[2],
|
|
2095
|
+
mat.edgeColor[3],
|
|
2096
|
+
mat.edgeSize,
|
|
2097
|
+
0,
|
|
2098
|
+
0,
|
|
2099
|
+
0,
|
|
1358
2100
|
]);
|
|
1359
2101
|
const outlineUniformBuffer = this.createUniformBuffer(`${prefix}outline: ${mat.name}`, materialUniformData);
|
|
1360
2102
|
inst.gpuBuffers.push(outlineUniformBuffer);
|
|
1361
2103
|
const outlineBindGroup = this.device.createBindGroup({
|
|
1362
2104
|
label: `${prefix}outline: ${mat.name}`,
|
|
1363
2105
|
layout: this.outlinePerMaterialBindGroupLayout,
|
|
1364
|
-
entries: [
|
|
1365
|
-
{ binding: 0, resource: { buffer: outlineUniformBuffer } },
|
|
1366
|
-
],
|
|
2106
|
+
entries: [{ binding: 0, resource: { buffer: outlineUniformBuffer } }],
|
|
1367
2107
|
});
|
|
1368
2108
|
const outlineType = isTransparent ? "transparent-outline" : "opaque-outline";
|
|
1369
|
-
inst.drawCalls.push({
|
|
2109
|
+
inst.drawCalls.push({
|
|
2110
|
+
type: outlineType,
|
|
2111
|
+
count: indexCount,
|
|
2112
|
+
firstIndex: currentIndexOffset,
|
|
2113
|
+
bindGroup: outlineBindGroup,
|
|
2114
|
+
materialName: mat.name,
|
|
2115
|
+
preset,
|
|
2116
|
+
});
|
|
1370
2117
|
}
|
|
1371
2118
|
if (this.onRaycast) {
|
|
1372
2119
|
const pickIdData = new Float32Array([modelId, materialId, 0, 0]);
|
|
@@ -1386,18 +2133,13 @@ export class Engine {
|
|
|
1386
2133
|
inst.shadowDrawCalls.push(d);
|
|
1387
2134
|
}
|
|
1388
2135
|
}
|
|
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
|
-
]);
|
|
2136
|
+
createMaterialUniformBuffer(label, alpha, diffuseColor) {
|
|
2137
|
+
// Matches WGSL `struct MaterialUniforms { diffuseColor: vec3f, alpha: f32 }` — 16 bytes.
|
|
2138
|
+
const data = new Float32Array(4);
|
|
2139
|
+
data[0] = diffuseColor[0];
|
|
2140
|
+
data[1] = diffuseColor[1];
|
|
2141
|
+
data[2] = diffuseColor[2];
|
|
2142
|
+
data[3] = alpha;
|
|
1401
2143
|
return this.createUniformBuffer(`material uniform: ${label}`, data);
|
|
1402
2144
|
}
|
|
1403
2145
|
createUniformBuffer(label, data) {
|
|
@@ -1424,16 +2166,20 @@ export class Engine {
|
|
|
1424
2166
|
premultiplyAlpha: "none",
|
|
1425
2167
|
colorSpaceConversion: "none",
|
|
1426
2168
|
});
|
|
2169
|
+
const mipLevelCount = Math.floor(Math.log2(Math.max(imageBitmap.width, imageBitmap.height))) + 1;
|
|
1427
2170
|
const texture = this.device.createTexture({
|
|
1428
2171
|
label: `texture: ${cacheKey}`,
|
|
1429
2172
|
size: [imageBitmap.width, imageBitmap.height],
|
|
1430
|
-
format: "rgba8unorm",
|
|
2173
|
+
format: "rgba8unorm-srgb",
|
|
2174
|
+
mipLevelCount,
|
|
1431
2175
|
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
1432
2176
|
});
|
|
1433
2177
|
this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
|
|
1434
2178
|
imageBitmap.width,
|
|
1435
2179
|
imageBitmap.height,
|
|
1436
2180
|
]);
|
|
2181
|
+
if (mipLevelCount > 1)
|
|
2182
|
+
this.generateMipmaps(texture, mipLevelCount);
|
|
1437
2183
|
this.textureCache.set(cacheKey, texture);
|
|
1438
2184
|
inst.textureCacheKeys.push(cacheKey);
|
|
1439
2185
|
return texture;
|
|
@@ -1442,6 +2188,62 @@ export class Engine {
|
|
|
1442
2188
|
return null;
|
|
1443
2189
|
}
|
|
1444
2190
|
}
|
|
2191
|
+
// Bilinear box-filter downsample per level. Reads srgb view (hardware linearizes on sample,
|
|
2192
|
+
// re-encodes on write), so intensities are filtered in linear space — matching EEVEE/Blender.
|
|
2193
|
+
generateMipmaps(texture, mipLevelCount) {
|
|
2194
|
+
if (!this.mipBlitPipeline || !this.mipBlitSampler) {
|
|
2195
|
+
this.mipBlitSampler = this.device.createSampler({
|
|
2196
|
+
magFilter: "linear",
|
|
2197
|
+
minFilter: "linear",
|
|
2198
|
+
addressModeU: "clamp-to-edge",
|
|
2199
|
+
addressModeV: "clamp-to-edge",
|
|
2200
|
+
});
|
|
2201
|
+
const module = this.device.createShaderModule({
|
|
2202
|
+
label: "mipmap blit",
|
|
2203
|
+
code: /* wgsl */ `
|
|
2204
|
+
@group(0) @binding(0) var src: texture_2d<f32>;
|
|
2205
|
+
@group(0) @binding(1) var samp: sampler;
|
|
2206
|
+
@vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
|
2207
|
+
let x = f32((vi & 1u) << 2u) - 1.0;
|
|
2208
|
+
let y = f32((vi & 2u) << 1u) - 1.0;
|
|
2209
|
+
return vec4f(x, y, 0.0, 1.0);
|
|
2210
|
+
}
|
|
2211
|
+
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
|
|
2212
|
+
let dstDims = vec2f(textureDimensions(src)) * 0.5;
|
|
2213
|
+
let uv = p.xy / max(dstDims, vec2f(1.0));
|
|
2214
|
+
return textureSampleLevel(src, samp, uv, 0.0);
|
|
2215
|
+
}
|
|
2216
|
+
`,
|
|
2217
|
+
});
|
|
2218
|
+
this.mipBlitPipeline = this.device.createRenderPipeline({
|
|
2219
|
+
label: "mipmap blit pipeline",
|
|
2220
|
+
layout: "auto",
|
|
2221
|
+
vertex: { module, entryPoint: "vs" },
|
|
2222
|
+
fragment: { module, entryPoint: "fs", targets: [{ format: "rgba8unorm-srgb" }] },
|
|
2223
|
+
primitive: { topology: "triangle-list" },
|
|
2224
|
+
});
|
|
2225
|
+
}
|
|
2226
|
+
const encoder = this.device.createCommandEncoder({ label: "mipgen" });
|
|
2227
|
+
for (let level = 1; level < mipLevelCount; level++) {
|
|
2228
|
+
const srcView = texture.createView({ baseMipLevel: level - 1, mipLevelCount: 1 });
|
|
2229
|
+
const dstView = texture.createView({ baseMipLevel: level, mipLevelCount: 1 });
|
|
2230
|
+
const bindGroup = this.device.createBindGroup({
|
|
2231
|
+
layout: this.mipBlitPipeline.getBindGroupLayout(0),
|
|
2232
|
+
entries: [
|
|
2233
|
+
{ binding: 0, resource: srcView },
|
|
2234
|
+
{ binding: 1, resource: this.mipBlitSampler },
|
|
2235
|
+
],
|
|
2236
|
+
});
|
|
2237
|
+
const pass = encoder.beginRenderPass({
|
|
2238
|
+
colorAttachments: [{ view: dstView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" }],
|
|
2239
|
+
});
|
|
2240
|
+
pass.setPipeline(this.mipBlitPipeline);
|
|
2241
|
+
pass.setBindGroup(0, bindGroup);
|
|
2242
|
+
pass.draw(3);
|
|
2243
|
+
pass.end();
|
|
2244
|
+
}
|
|
2245
|
+
this.device.queue.submit([encoder.finish()]);
|
|
2246
|
+
}
|
|
1445
2247
|
renderGround(pass) {
|
|
1446
2248
|
if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall)
|
|
1447
2249
|
return;
|
|
@@ -1463,12 +2265,14 @@ export class Engine {
|
|
|
1463
2265
|
if (!this.pendingPick || !this.pickTexture || !this.pickDepthTexture)
|
|
1464
2266
|
return;
|
|
1465
2267
|
const pass = encoder.beginRenderPass({
|
|
1466
|
-
colorAttachments: [
|
|
2268
|
+
colorAttachments: [
|
|
2269
|
+
{
|
|
1467
2270
|
view: this.pickTexture.createView(),
|
|
1468
2271
|
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
1469
2272
|
loadOp: "clear",
|
|
1470
2273
|
storeOp: "store",
|
|
1471
|
-
}
|
|
2274
|
+
},
|
|
2275
|
+
],
|
|
1472
2276
|
depthStencilAttachment: {
|
|
1473
2277
|
view: this.pickDepthTexture.createView(),
|
|
1474
2278
|
depthClearValue: 1.0,
|
|
@@ -1543,7 +2347,6 @@ export class Engine {
|
|
|
1543
2347
|
const currentTime = performance.now();
|
|
1544
2348
|
const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016;
|
|
1545
2349
|
this.lastFrameTime = currentTime;
|
|
1546
|
-
this.updateRenderTarget();
|
|
1547
2350
|
const hasModels = this.modelInstances.size > 0;
|
|
1548
2351
|
if (hasModels) {
|
|
1549
2352
|
this.updateInstances(deltaTime);
|
|
@@ -1560,10 +2363,9 @@ export class Engine {
|
|
|
1560
2363
|
}
|
|
1561
2364
|
}
|
|
1562
2365
|
this.updateCameraUniforms();
|
|
1563
|
-
|
|
1564
|
-
this.updateShadowLightVP();
|
|
2366
|
+
this.updateShadowLightVP();
|
|
1565
2367
|
const encoder = this.device.createCommandEncoder();
|
|
1566
|
-
if (hasModels
|
|
2368
|
+
if (hasModels) {
|
|
1567
2369
|
const sp = encoder.beginRenderPass({
|
|
1568
2370
|
colorAttachments: [],
|
|
1569
2371
|
depthStencilAttachment: {
|
|
@@ -1583,6 +2385,51 @@ export class Engine {
|
|
|
1583
2385
|
if (this.hasGround)
|
|
1584
2386
|
this.renderGround(pass);
|
|
1585
2387
|
pass.end();
|
|
2388
|
+
// Bloom pyramid (EEVEE 3.6):
|
|
2389
|
+
// 1. Blit: HDR → bloomDown[0] (Karis prefilter, half-res)
|
|
2390
|
+
// 2. Downsample: bloomDown[0] → bloomDown[1] → … → bloomDown[N-1] (13-tap)
|
|
2391
|
+
// 3. Upsample (top-down): bloomUp[N-2] = tent(bloomDown[N-1]) + bloomDown[N-2],
|
|
2392
|
+
// then bloomUp[i] = tent(bloomUp[i+1]) + bloomDown[i] until i=0 (9-tap tent)
|
|
2393
|
+
// Composite reads bloomUp[0] and adds tint * intensity * bloom before Filmic.
|
|
2394
|
+
if (this.bloomBlitBindGroup && this.compositeBindGroup && this.bloomMipCount > 0) {
|
|
2395
|
+
const bloomAtt = this.bloomPassDescriptor.colorAttachments;
|
|
2396
|
+
// 1. Blit
|
|
2397
|
+
bloomAtt[0].view = this.bloomDownMipViews[0];
|
|
2398
|
+
const pBlit = encoder.beginRenderPass(this.bloomPassDescriptor);
|
|
2399
|
+
pBlit.setPipeline(this.bloomBlitPipeline);
|
|
2400
|
+
pBlit.setBindGroup(0, this.bloomBlitBindGroup);
|
|
2401
|
+
pBlit.draw(3);
|
|
2402
|
+
pBlit.end();
|
|
2403
|
+
// 2. Downsample chain
|
|
2404
|
+
for (let i = 1; i < this.bloomMipCount; i++) {
|
|
2405
|
+
bloomAtt[0].view = this.bloomDownMipViews[i];
|
|
2406
|
+
const p = encoder.beginRenderPass(this.bloomPassDescriptor);
|
|
2407
|
+
p.setPipeline(this.bloomDownsamplePipeline);
|
|
2408
|
+
p.setBindGroup(0, this.bloomDownsampleBindGroups[i - 1]);
|
|
2409
|
+
p.draw(3);
|
|
2410
|
+
p.end();
|
|
2411
|
+
}
|
|
2412
|
+
// 3. Upsample chain (coarsest to finest; bindGroups[0] is the coarsest step)
|
|
2413
|
+
const upSteps = this.bloomUpsampleBindGroups.length;
|
|
2414
|
+
const topIdx = this.bloomMipCount - 2;
|
|
2415
|
+
for (let k = 0; k < upSteps; k++) {
|
|
2416
|
+
const levelIdx = topIdx - k; // writes bloomUp[levelIdx]
|
|
2417
|
+
bloomAtt[0].view = this.bloomUpMipViews[levelIdx];
|
|
2418
|
+
const p = encoder.beginRenderPass(this.bloomPassDescriptor);
|
|
2419
|
+
p.setPipeline(this.bloomUpsamplePipeline);
|
|
2420
|
+
p.setBindGroup(0, this.bloomUpsampleBindGroups[k]);
|
|
2421
|
+
p.draw(3);
|
|
2422
|
+
p.end();
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
// Composite: HDR + bloom → Filmic tonemap → swapchain.
|
|
2426
|
+
const compositeAttachment = this.compositePassDescriptor.colorAttachments[0];
|
|
2427
|
+
compositeAttachment.view = this.context.getCurrentTexture().createView();
|
|
2428
|
+
const cpass = encoder.beginRenderPass(this.compositePassDescriptor);
|
|
2429
|
+
cpass.setPipeline(this.compositePipeline);
|
|
2430
|
+
cpass.setBindGroup(0, this.compositeBindGroup);
|
|
2431
|
+
cpass.draw(3);
|
|
2432
|
+
cpass.end();
|
|
1586
2433
|
const pick = this.pendingPick;
|
|
1587
2434
|
if (pick && hasModels)
|
|
1588
2435
|
this.renderPickPass(encoder);
|
|
@@ -1594,10 +2441,6 @@ export class Engine {
|
|
|
1594
2441
|
}
|
|
1595
2442
|
this.updateStats(performance.now() - currentTime);
|
|
1596
2443
|
}
|
|
1597
|
-
updateRenderTarget() {
|
|
1598
|
-
const colorAttachment = this.renderPassDescriptor.colorAttachments[0];
|
|
1599
|
-
colorAttachment.resolveTarget = this.context.getCurrentTexture().createView();
|
|
1600
|
-
}
|
|
1601
2444
|
drawInstanceShadow(sp, inst) {
|
|
1602
2445
|
sp.setBindGroup(0, inst.shadowBindGroup);
|
|
1603
2446
|
sp.setVertexBuffer(0, inst.vertexBuffer);
|
|
@@ -1609,39 +2452,87 @@ export class Engine {
|
|
|
1609
2452
|
sp.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
1610
2453
|
}
|
|
1611
2454
|
}
|
|
1612
|
-
|
|
1613
|
-
|
|
2455
|
+
pipelineForPreset(preset) {
|
|
2456
|
+
if (preset === "face")
|
|
2457
|
+
return this.facePipeline;
|
|
2458
|
+
if (preset === "hair")
|
|
2459
|
+
return this.hairPipeline;
|
|
2460
|
+
if (preset === "cloth_smooth")
|
|
2461
|
+
return this.clothSmoothPipeline;
|
|
2462
|
+
if (preset === "cloth_rough")
|
|
2463
|
+
return this.clothRoughPipeline;
|
|
2464
|
+
if (preset === "metal")
|
|
2465
|
+
return this.metalPipeline;
|
|
2466
|
+
if (preset === "body")
|
|
2467
|
+
return this.bodyPipeline;
|
|
2468
|
+
if (preset === "eye")
|
|
2469
|
+
return this.eyePipeline;
|
|
2470
|
+
if (preset === "stockings")
|
|
2471
|
+
return this.stockingsPipeline;
|
|
2472
|
+
return this.modelPipeline;
|
|
2473
|
+
}
|
|
2474
|
+
/**
|
|
2475
|
+
* Draw every material of a given type (`opaque` or `transparent`) using the main
|
|
2476
|
+
* pipeline(s). Binds the per-frame and per-instance groups once at the top of the
|
|
2477
|
+
* batch, then issues one draw per material. Early-outs if nothing to draw so we
|
|
2478
|
+
* don't waste bindings when a model has no transparents, etc.
|
|
2479
|
+
*/
|
|
2480
|
+
drawMaterials(pass, inst, type) {
|
|
2481
|
+
let currentPipeline = null;
|
|
2482
|
+
let bound = false;
|
|
1614
2483
|
for (const draw of inst.drawCalls) {
|
|
1615
|
-
if (draw.type
|
|
1616
|
-
|
|
1617
|
-
|
|
2484
|
+
if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw))
|
|
2485
|
+
continue;
|
|
2486
|
+
if (!bound) {
|
|
2487
|
+
pass.setBindGroup(0, this.perFrameBindGroup);
|
|
2488
|
+
pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
|
|
2489
|
+
bound = true;
|
|
1618
2490
|
}
|
|
2491
|
+
const pipeline = this.pipelineForPreset(draw.preset);
|
|
2492
|
+
if (pipeline !== currentPipeline) {
|
|
2493
|
+
pass.setPipeline(pipeline);
|
|
2494
|
+
currentPipeline = pipeline;
|
|
2495
|
+
}
|
|
2496
|
+
pass.setBindGroup(2, draw.bindGroup);
|
|
2497
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
1619
2498
|
}
|
|
1620
2499
|
}
|
|
1621
|
-
|
|
1622
|
-
|
|
2500
|
+
/**
|
|
2501
|
+
* Draw every outline of a given type (`opaque-outline` or `transparent-outline`).
|
|
2502
|
+
* Uses its own pipeline layout (group 0 = camera-only, group 2 = edge uniforms), so
|
|
2503
|
+
* every batch binds its own groups from scratch — the next drawMaterials call will
|
|
2504
|
+
* rebind group 0/1 correctly if needed.
|
|
2505
|
+
*/
|
|
2506
|
+
drawOutlines(pass, inst, type) {
|
|
2507
|
+
let bound = false;
|
|
1623
2508
|
for (const draw of inst.drawCalls) {
|
|
1624
|
-
if (draw.type
|
|
1625
|
-
|
|
1626
|
-
|
|
2509
|
+
if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw))
|
|
2510
|
+
continue;
|
|
2511
|
+
if (!bound) {
|
|
2512
|
+
pass.setPipeline(this.outlinePipeline);
|
|
2513
|
+
pass.setBindGroup(0, this.outlinePerFrameBindGroup);
|
|
2514
|
+
pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
|
|
2515
|
+
bound = true;
|
|
1627
2516
|
}
|
|
2517
|
+
pass.setBindGroup(2, draw.bindGroup);
|
|
2518
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
1628
2519
|
}
|
|
1629
2520
|
}
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
2521
|
+
/**
|
|
2522
|
+
* Main-pass render sequence for one model instance:
|
|
2523
|
+
* 1) opaque bodies → 2) opaque outlines → 3) transparents → 4) transparent outlines.
|
|
2524
|
+
* Each batch binds the groups it needs, so switching between main and outline
|
|
2525
|
+
* pipelines is self-contained (no cross-batch dependencies).
|
|
2526
|
+
*/
|
|
1634
2527
|
renderOneModel(pass, inst) {
|
|
1635
2528
|
pass.setVertexBuffer(0, inst.vertexBuffer);
|
|
1636
2529
|
pass.setVertexBuffer(1, inst.jointsBuffer);
|
|
1637
2530
|
pass.setVertexBuffer(2, inst.weightsBuffer);
|
|
1638
2531
|
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);
|
|
2532
|
+
this.drawMaterials(pass, inst, "opaque");
|
|
2533
|
+
this.drawOutlines(pass, inst, "opaque-outline");
|
|
2534
|
+
this.drawMaterials(pass, inst, "transparent");
|
|
2535
|
+
this.drawOutlines(pass, inst, "transparent-outline");
|
|
1645
2536
|
}
|
|
1646
2537
|
updateCameraUniforms() {
|
|
1647
2538
|
const viewMatrix = this.camera.getViewMatrix();
|
|
@@ -1660,18 +2551,6 @@ export class Engine {
|
|
|
1660
2551
|
this.device.queue.writeBuffer(inst.skinMatrixBuffer, 0, skinMatrices.buffer, skinMatrices.byteOffset, skinMatrices.byteLength);
|
|
1661
2552
|
});
|
|
1662
2553
|
}
|
|
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
2554
|
updateStats(frameTime) {
|
|
1676
2555
|
// Simplified frame time tracking - rolling average with fixed window
|
|
1677
2556
|
const maxSamples = 60;
|
|
@@ -1697,3 +2576,10 @@ export class Engine {
|
|
|
1697
2576
|
}
|
|
1698
2577
|
Engine.instance = null;
|
|
1699
2578
|
Engine.MULTISAMPLE_COUNT = 4;
|
|
2579
|
+
Engine.HDR_FORMAT = "rgba16float";
|
|
2580
|
+
/** Single-channel mask written alongside HDR color — 1 = model geometry (contributes
|
|
2581
|
+
* to bloom), 0 = ground (never blooms). Sampled by the bloom blit pass to gate the
|
|
2582
|
+
* prefilter so ground brightness can't halo the scene. */
|
|
2583
|
+
Engine.BLOOM_MASK_FORMAT = "r8unorm";
|
|
2584
|
+
Engine.BLOOM_MAX_LEVELS = 7;
|
|
2585
|
+
Engine.SHADOW_MAP_SIZE = 2048;
|