reze-engine 0.55.16 → 0.56.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.
@@ -0,0 +1,284 @@
1
+ // The mirror surface — the one thing that consumes the reflection pass as a
2
+ // SURFACE rather than as a layer under the floor.
3
+ //
4
+ // The ground has sampled the mirror target since step 7D, and it does it the
5
+ // only way a planar reflection can be sampled correctly: project the fragment's
6
+ // world position through the camera that rendered the reflection, and the texel
7
+ // you land on is exactly where that fragment's reflection was drawn. No
8
+ // screen-space ray, no guess. This file is that same projection on a quad you
9
+ // can put anywhere, which is all a standing mirror is.
10
+ //
11
+ // What it deliberately does NOT do:
12
+ // - light. A mirror shows what it reflects; a sun term on top of that is a
13
+ // second surface nobody asked for.
14
+ // - receive shadow. The reflection already carries the shadow the reflected
15
+ // geometry is standing in.
16
+ // - carry a strength. Coverage is 1 — the tint is how a mirror falls short of
17
+ // perfect, the same way real glass does, and it is a colour rather than a
18
+ // dial that fades the surface out into nothing.
19
+ import { sceneFsOutWgsl, sceneIdPadWgsl } from "./scene-contract";
20
+ /** Bytes in MirrorMat below: mat4x4f + three 16-byte rows. */
21
+ export const MIRROR_MAT_BYTES = 64 + 16 * 3;
22
+ export function mirrorShaderWgsl() {
23
+ return /* wgsl */ `
24
+ struct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };
25
+ // params = (projA, projB, _, _) — the depth-linearisation pair, carried beside
26
+ // the matrix exactly as the ground reads it. Unused here (the surface takes a
27
+ // flat blur level, not a depth-proportional one) and kept so the two consumers
28
+ // bind one buffer rather than two shapes of the same thing.
29
+ struct MirrorVP { viewProj: mat4x4f, params: vec4f, };
30
+ struct MirrorMat {
31
+ model: mat4x4f,
32
+ tint: vec3f, blur: f32,
33
+ // frame = the band's width in WORLD units, 0 for a bare pane. World rather
34
+ // than a fraction of the quad so that a wide mirror and a narrow one in the
35
+ // same scene are framed in the same moulding.
36
+ frameColor: vec3f, frame: f32,
37
+ size: vec2f, _pad: vec2f,
38
+ };
39
+
40
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
41
+ @group(0) @binding(1) var<uniform> mirrorVP: MirrorVP;
42
+ @group(0) @binding(2) var<uniform> material: MirrorMat;
43
+ @group(0) @binding(3) var mirrorTex: texture_2d<f32>;
44
+ @group(0) @binding(4) var linearSampler: sampler;
45
+ // The mirror pass's aux, resolved. .g is accumulated alpha — the reflection's
46
+ // COVERAGE, and the only place it exists: the HDR attachment is rg11b10ufloat
47
+ // wherever the device allows it, and that format has no alpha channel, so a
48
+ // mirror reading the colour target's own alpha reads 1 everywhere and paints
49
+ // the empty black of the reflection across the whole pane.
50
+ @group(0) @binding(5) var mirrorMask: texture_2d<f32>;
51
+
52
+ struct VO {
53
+ @builtin(position) position: vec4f,
54
+ @location(0) worldPos: vec3f,
55
+ @location(1) normal: vec3f,
56
+ /** Across the quad, 0..1 — what the frame is measured in. */
57
+ @location(2) uv: vec2f,
58
+ };
59
+
60
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> VO {
61
+ // The quad is generated, not buffered: it is six vertices of a unit square
62
+ // and the model matrix carries every difference between one mirror and
63
+ // another, so a vertex buffer would be an allocation per mirror holding the
64
+ // same twelve numbers.
65
+ //
66
+ // Local XY, facing local +Z. A mirror at identity therefore faces the way the
67
+ // default camera looks FROM, which is the orientation someone dropping one
68
+ // into a scene means by "in front of her".
69
+ // Local Y runs 0..1 from the BASE, not -0.5..0.5 from the centre. A mirror
70
+ // stands on the floor: shortening it has to take the height off the top and
71
+ // leave the foot where it is, and turning it has to pivot on that foot. With
72
+ // a centred quad both of those move the whole mirror.
73
+ var q = array<vec2f, 6>(
74
+ vec2f(-0.5, 0.0), vec2f(0.5, 0.0), vec2f(0.5, 1.0),
75
+ vec2f(-0.5, 0.0), vec2f(0.5, 1.0), vec2f(-0.5, 1.0),
76
+ );
77
+ let local = vec4f(q[vi], 0.0, 1.0);
78
+ let uv = q[vi] + vec2f(0.5, 0.0);
79
+ let world = material.model * local;
80
+ var o: VO;
81
+ o.worldPos = world.xyz;
82
+ o.uv = uv;
83
+ // The local +Z axis through the model's rotation. Column 2 of a matrix whose
84
+ // scale is uniform per axis is that axis scaled — normalized here because the
85
+ // side test below is a sign, and a scaled normal would still give the right
86
+ // sign but a wrong-length one is a trap for whatever reads this next.
87
+ o.normal = normalize((material.model * vec4f(0.0, 0.0, 1.0, 0.0)).xyz);
88
+ o.position = camera.projection * camera.view * world;
89
+ return o;
90
+ }
91
+
92
+ ${sceneFsOutWgsl()}@fragment fn fs(i: VO) -> FSOut {
93
+ var out: FSOut;
94
+ ${sceneIdPadWgsl("out")}
95
+ let toEye = camera.viewPos - i.worldPos;
96
+ // Facing the viewer whichever side is up: the quad has one normal and two
97
+ // sides, and the moulding is on both.
98
+ let nrm = select(-i.normal, i.normal, dot(toEye, i.normal) > 0.0);
99
+
100
+ // ── The moulding ──
101
+ //
102
+ // Measured in world units off each edge, so the width means the same thing on
103
+ // a tall mirror as on a wide one.
104
+ let dx = min(i.uv.x, 1.0 - i.uv.x) * material.size.x;
105
+ let dy = min(i.uv.y, 1.0 - i.uv.y) * material.size.y;
106
+ let edge = min(dx, dy);
107
+ if (edge < material.frame) {
108
+ // A MOULDING with a cross-section, not a painted border. The band's normal
109
+ // rolls from facing outward at the rim to facing the viewer at the inner
110
+ // lip, and that roll is the whole trick: a flat rectangle of colour reads as
111
+ // a decal on a plane, while a normal that turns catches the view and reads
112
+ // as metal bent around glass.
113
+ let t = clamp(edge / max(material.frame, 1e-4), 0.0, 1.0);
114
+ // Which way this point of the band faces, in the plane. The axes come off
115
+ // the model matrix, so a rotated mirror's moulding rotates with it.
116
+ let ax = normalize(material.model[0].xyz);
117
+ let ay = normalize(material.model[1].xyz);
118
+ var outward = ax * select(-1.0, 1.0, i.uv.x > 0.5);
119
+ if (dy < dx) { outward = ay * select(-1.0, 1.0, i.uv.y > 0.5); }
120
+ let roll = cos(t * 1.5707963);
121
+ let n = normalize(nrm * (1.0 - roll) + outward * roll);
122
+ let v = normalize(toEye);
123
+ // Two FIXED studio lights, not the scene's. A frame lit by the scene goes
124
+ // black whenever the sun is behind it, and a black frame reads as a hole cut
125
+ // in the picture rather than as metal in shadow.
126
+ let l1 = normalize(vec3f(0.40, 0.80, 0.45));
127
+ let l2 = normalize(vec3f(-0.50, 0.20, -0.60));
128
+ let spec = pow(max(dot(n, normalize(l1 + v)), 0.0), 64.0) * 2.6
129
+ + pow(max(dot(n, normalize(l2 + v)), 0.0), 20.0) * 0.7;
130
+ let diff = max(dot(n, l1), 0.0) * 0.55 + max(dot(n, l2), 0.0) * 0.25;
131
+ // The grazing rim is what makes a metal edge read as an edge.
132
+ let fres = pow(1.0 - abs(dot(n, v)), 4.0);
133
+ // A METAL TINTS ITS OWN HIGHLIGHT. White specular on a gold frame reads as
134
+ // cream plastic; carrying the frame's colour into the highlight and the rim
135
+ // is what separates gold from a yellow-painted thing.
136
+ let sheen = mix(vec3f(1.0), material.frameColor, 0.65);
137
+ out.color = vec4f(material.frameColor * (0.45 + diff) + sheen * (spec + fres * 0.40), 1.0);
138
+ out.mask = vec4f(0.0, 1.0, 0.0, 1.0);
139
+ return out;
140
+ }
141
+
142
+ // Which side is being looked at. Behind a mirror there is no reflection to
143
+ // show — the fold puts the mirror camera on the viewer's own side and the
144
+ // projection returns a smear — so the back reads as what the back of a mirror
145
+ // is: a flat panel. Without this a mirror rotated the wrong way is not
146
+ // wrong-looking, it is broken-looking, and the fix is invisible from the
147
+ // transform panel.
148
+ if (dot(toEye, i.normal) <= 0.0) {
149
+ // The BACKING BOARD. A mirror seen from behind is a board in a frame, and
150
+ // it is opaque: the scene must not show through it, or the pane reads as a
151
+ // pane of dark glass with the cast standing inside it.
152
+ out.color = vec4f(material.frameColor * 0.55, 1.0);
153
+ out.mask = vec4f(0.0, 1.0, 0.0, 1.0);
154
+ return out;
155
+ }
156
+
157
+ // The projective sample: this fragment's world position through the mirror
158
+ // camera IS the texel its reflection landed on.
159
+ let mc = mirrorVP.viewProj * vec4f(i.worldPos, 1.0);
160
+ let mndc = mc.xyz / max(mc.w, 1e-6);
161
+ let muv = clamp(vec2f(mndc.x * 0.5 + 0.5, 0.5 - mndc.y * 0.5), vec2f(0.0), vec2f(1.0));
162
+ // A flat level, not the ground's depth-proportional one. That trick
163
+ // reconstructs how far behind the surface the reflected geometry sits by
164
+ // mirroring the eye through y — arithmetic that is only true for a floor.
165
+ // The general form belongs here eventually; a uniform frosting is the honest
166
+ // thing to ship before it, and blur 0 (the default) reads level 0 exactly.
167
+ let refl = textureSampleLevel(mirrorTex, linearSampler, muv, material.blur * 5.0);
168
+ // THE SAME LEVEL as the colour, and that is the whole of it. refl.rgb is
169
+ // premultiplied by this coverage, so the two are one quantity in two
170
+ // textures: blur the colour into the empty part of the reflection while
171
+ // reading coverage sharp, and the pane divides a spread-out colour by a
172
+ // coverage that never spread — which is a reflection that goes dark as the
173
+ // frosting comes up, exactly backwards.
174
+ let cov = textureSampleLevel(mirrorMask, linearSampler, muv, material.blur * 5.0).g;
175
+
176
+ // ALPHA IS COVERAGE, and it is what keeps the glass clear.
177
+ //
178
+ // The reflection target clears TRANSPARENT, so coverage is 1 exactly where
179
+ // the mirror pass drew something and 0 where it drew nothing. Filling the empty
180
+ // part with the scene's background colour is the obvious thing and it is
181
+ // wrong: that colour would then ride the view transform a second time (the
182
+ // real backdrop composites after it), and the panel comes out paler and
183
+ // flatter than the backdrop it sits against — a mirror that fogs.
184
+ //
185
+ // Letting it through instead costs nothing and is also what a mirror does: an
186
+ // empty mirror shows the room, and here the room IS the backdrop.
187
+ // PREMULTIPLIED, and it must not be premultiplied twice. refl.rgb came out of
188
+ // the HDR target, which stores colour already weighted by its own alpha, and
189
+ // the mirror class blends premultiplied for exactly that reason. Multiplying
190
+ // by cov here as well is what made the reflection a ghost.
191
+ //
192
+ // ALPHA 1, ALWAYS — the pane REPLACES what is behind it. A mirror is opaque;
193
+ // blending here let the real floor show through the glass beside the
194
+ // reflected one, which is two grids in one pane. Premultiplied over at alpha
195
+ // 1 is a straight overwrite, and refl.rgb is already premultiplied, so the
196
+ // empty sky of the reflection writes 0 rather than black-over-something.
197
+ out.color = vec4f(refl.rgb * material.tint, 1.0);
198
+ // .g is the ACCUMULATED CANVAS ALPHA the composite un-premultiplies by, and a
199
+ // surface that writes nothing here is a surface the composite treats as
200
+ // absent — the reflection then survived only where something else had already
201
+ // written alpha under it, which is to say only against the reflected floor.
202
+ // .r is the bloom gate, left at 0: the glow of a reflected ribbon is already
203
+ // in refl.rgb, and gating it again would bloom the one thing in the scene
204
+ // most likely to be over the threshold twice.
205
+ // COVERAGE goes here instead, and it REPLACES too (.a = 1, so the alpha-over
206
+ // blend takes the source whole). The composite reads .g as accumulated alpha:
207
+ // it un-premultiplies the colour by it and paints the scene's backdrop where
208
+ // it falls short. That is what puts the true sky behind the reflection at the
209
+ // true colour — the pane cannot paint the backdrop itself, because anything
210
+ // it writes here goes through the view transform a second time.
211
+ out.mask = vec4f(0.0, cov, 0.0, 1.0);
212
+ return out;
213
+ }
214
+ `;
215
+ }
216
+ /**
217
+ * The mirror in the SHADOW pass — the frame throwing shade on the floor.
218
+ *
219
+ * Its own shader because the mirror has no vertex buffer and no skeleton: the
220
+ * quad is six generated vertices and the model matrix is the whole of it, while
221
+ * the ordinary shadow shader is built for skinned geometry with an alpha test.
222
+ *
223
+ * Depth only, no fragment stage. The pane is opaque across its whole rectangle
224
+ * — glass included, because this glass is not see-through — so there is nothing
225
+ * to alpha-test and nothing to colour.
226
+ *
227
+ * The cascade index arrives in its own tiny uniform rather than as a dynamic
228
+ * offset: the cascade matrices sit 64 bytes apart in one array, and a dynamic
229
+ * uniform offset has to be 256-aligned.
230
+ */
231
+ /**
232
+ * Downsample for the reflection's COVERAGE chain.
233
+ *
234
+ * Colour and coverage are a premultiplied PAIR, and a blur has to move them
235
+ * together. Blurring colour alone drags it toward zero wherever it spreads into
236
+ * the empty part of the reflection, while a coverage still sampled sharp says
237
+ * "fully covered" — so the reflection simply got darker as the dial came up.
238
+ *
239
+ * One bilinear tap at the destination texel centre averages the source's 2x2
240
+ * exactly, which is the box filter this wants and the cheapest way to write it.
241
+ */
242
+ export const MIRROR_MASK_DOWNSAMPLE_WGSL = /* wgsl */ `
243
+ @group(0) @binding(0) var src: texture_2d<f32>;
244
+ @group(0) @binding(1) var samp: sampler;
245
+
246
+ struct VSOut { @builtin(position) pos: vec4f, @location(0) uv: vec2f, };
247
+
248
+ @vertex fn vs(@builtin(vertex_index) i: u32) -> VSOut {
249
+ var out: VSOut;
250
+ let x = f32(i32(i / 2u) * 4 - 1);
251
+ let y = f32(i32(i % 2u) * 4 - 1);
252
+ out.pos = vec4f(x, y, 0.0, 1.0);
253
+ out.uv = vec2f(x * 0.5 + 0.5, 0.5 - y * 0.5);
254
+ return out;
255
+ }
256
+
257
+ @fragment fn fs(in: VSOut) -> @location(0) vec4f {
258
+ return textureSampleLevel(src, samp, in.uv, 0.0);
259
+ }
260
+ `;
261
+ export function mirrorShadowWgsl(cascades) {
262
+ return /* wgsl */ `
263
+ struct LightVP { viewProj: array<mat4x4f, ${cascades}>, };
264
+ struct Cascade { index: u32, _p0: u32, _p1: u32, _p2: u32, };
265
+ struct MirrorMat {
266
+ model: mat4x4f,
267
+ tint: vec3f, blur: f32,
268
+ frameColor: vec3f, frame: f32,
269
+ size: vec2f, _pad: vec2f,
270
+ };
271
+
272
+ @group(0) @binding(0) var<uniform> lightVP: LightVP;
273
+ @group(0) @binding(1) var<uniform> cascade: Cascade;
274
+ @group(0) @binding(2) var<uniform> material: MirrorMat;
275
+
276
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
277
+ var q = array<vec2f, 6>(
278
+ vec2f(-0.5, 0.0), vec2f(0.5, 0.0), vec2f(0.5, 1.0),
279
+ vec2f(-0.5, 0.0), vec2f(0.5, 1.0), vec2f(-0.5, 1.0),
280
+ );
281
+ return lightVP.viewProj[cascade.index] * material.model * vec4f(q[vi], 0.0, 1.0);
282
+ }
283
+ `;
284
+ }
@@ -59,6 +59,12 @@ type SceneRenderClass =
59
59
  | "ground"
60
60
  /** Backface-expanded hulls. Also a material blend — it is geometry. */
61
61
  | "outline"
62
+ /** A mirror surface: opaque geometry carrying a projective sample of the
63
+ * reflection target. Blends like a material because it IS one, and is out of
64
+ * WRITES_ID because what it shows is a picture of the cast rather than the
65
+ * cast — a reflection that claimed her object id would seed the distance
66
+ * field twice and put every silhouette effect's border around the glass. */
67
+ | "mirror"
62
68
  /** Particles and ribbons in their default, non-additive mode. */
63
69
  | "particle"
64
70
  /** Particles declaring `#blend additive` — LIGHT rather than matter, so
@@ -1 +1 @@
1
- {"version":3,"file":"scene-contract.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/scene-contract.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH;;uDAEuD;AACvD,MAAM,MAAM,YAAY,GAAG;IACzB,+CAA+C;IAC/C,GAAG,EAAE,gBAAgB,CAAA;IACrB,iEAAiE;IACjE,GAAG,EAAE,gBAAgB,CAAA;CACtB,CAAA;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,EAAE,gBAA6B,CAAA;AAkB3D,8DAA8D;AAC9D,wBAAgB,SAAS,CAAC,EAAE,EAAE,OAAO,GAAG,IAAI,CAE3C;AAED,wBAAgB,aAAa,IAAI,OAAO,CAEvC;AAED;;;;;;GAMG;AACH,KAAK,gBAAgB;AACnB,2DAA2D;AACzD,UAAU;AACZ;;+CAE+C;GAC7C,QAAQ;AACV,uEAAuE;GACrE,SAAS;AACX,iEAAiE;GAC/D,UAAU;AACZ;;;mDAGmD;GACjD,mBAAmB;AACrB;;;oCAGoC;GAClC,OAAO;AACT;;;kDAGkD;GAChD,eAAe,CAAA;AA0EnB;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,gBAAgB,EAAE,OAAO,EAAE,YAAY,GAAG,mBAAmB,EAAE,CAoBhG;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,YAAY,GAAG,gBAAgB,EAAE,CAE3E;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,cAAc,CAAC,IAAI,CAAC,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAmB7E;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAEtF;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,IAAI,MAAM,CAEzC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAElD"}
1
+ {"version":3,"file":"scene-contract.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/scene-contract.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH;;uDAEuD;AACvD,MAAM,MAAM,YAAY,GAAG;IACzB,+CAA+C;IAC/C,GAAG,EAAE,gBAAgB,CAAA;IACrB,iEAAiE;IACjE,GAAG,EAAE,gBAAgB,CAAA;CACtB,CAAA;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,EAAE,gBAA6B,CAAA;AAkB3D,8DAA8D;AAC9D,wBAAgB,SAAS,CAAC,EAAE,EAAE,OAAO,GAAG,IAAI,CAE3C;AAED,wBAAgB,aAAa,IAAI,OAAO,CAEvC;AAED;;;;;;GAMG;AACH,KAAK,gBAAgB;AACnB,2DAA2D;AACzD,UAAU;AACZ;;+CAE+C;GAC7C,QAAQ;AACV,uEAAuE;GACrE,SAAS;AACX;;;;6EAI6E;GAC3E,QAAQ;AACV,iEAAiE;GAC/D,UAAU;AACZ;;;mDAGmD;GACjD,mBAAmB;AACrB;;;oCAGoC;GAClC,OAAO;AACT;;;kDAGkD;GAChD,eAAe,CAAA;AAkFnB;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,gBAAgB,EAAE,OAAO,EAAE,YAAY,GAAG,mBAAmB,EAAE,CAoBhG;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,YAAY,GAAG,gBAAgB,EAAE,CAE3E;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,cAAc,CAAC,IAAI,CAAC,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAmB7E;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAEtF;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,IAAI,MAAM,CAEzC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAElD"}
@@ -104,11 +104,19 @@ const ADD_PREMULTIPLIED = {
104
104
  * consequence. Outline hulls, particles and ribbons are OUT: they are not
105
105
  * things you would ever address by id, and a hull would overwrite the id of the
106
106
  * body it traces.
107
+ *
108
+ * So is the mirror — see the class's own note.
107
109
  */
108
110
  const WRITES_ID = new Set(["material", "ground"]);
109
111
  /** The blends each class writes its two attachments with. */
110
112
  const BLENDS = {
111
113
  material: [ALPHA_OVER, ALPHA_OVER],
114
+ // PREMULTIPLIED, like the ground and for the same reason: what a mirror
115
+ // writes is a sample of the HDR target, and that target already holds colour
116
+ // premultiplied by its own alpha. Handed to the src-alpha blend it would be
117
+ // weighted a second time, and a reflection at coverage 0.5 would arrive at a
118
+ // quarter strength.
119
+ mirror: [PREMULTIPLIED_OVER, ALPHA_OVER],
112
120
  // PREMULTIPLIED colour, alone among the classes — see the blend's own note.
113
121
  // The aux is ordinary alpha-over: the ground writes its mask unweighted, like
114
122
  // everything else, and coverage is what the blend applies.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.55.16",
3
+ "version": "0.56.1",
4
4
  "description": "A lightweight WebGPU engine for real-time 3D MMD/PMX model rendering",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/camera.ts CHANGED
@@ -434,11 +434,31 @@ export class Camera {
434
434
 
435
435
  private onMouseDown(e: MouseEvent) {
436
436
  if (this.inputLocked) return
437
+ // Middle-click's own default is the browser's autoscroll cursor — since
438
+ // the middle button is orbit here (Blender's own binding), that default
439
+ // has to lose before a drag ever starts, not be cleaned up after.
440
+ if (e.button === 1) e.preventDefault()
437
441
  this.isDragging = true
438
442
  this.mouseButton = e.button
439
443
  this.lastMousePos = { x: e.clientX, y: e.clientY }
440
444
  }
441
445
 
446
+ /**
447
+ * Which button drags orbit the view. "left" is every consumer's original
448
+ * binding — anything that is not the pan button (right) rotates, which in
449
+ * practice means left, since a middle-drag was never a deliberate gesture
450
+ * this ever built for. "middle" is Blender's own binding, matching a
451
+ * dedicated orbit button against a dedicated pan button — opt-in only,
452
+ * via setOrbitButton, so a host that wants its own left button free for
453
+ * something else (a box-select, say) can ask for it without changing the
454
+ * default for every OTHER consumer of this engine.
455
+ */
456
+ private orbitButton: "left" | "middle" = "left"
457
+
458
+ setOrbitButton(button: "left" | "middle"): void {
459
+ this.orbitButton = button
460
+ }
461
+
442
462
  private onMouseMove(e: MouseEvent) {
443
463
  if (this.inputLocked || this.vmdDriven) return // VMD owns the camera; orbit/pan is inert
444
464
  if (!this.isDragging) return
@@ -447,13 +467,10 @@ export class Camera {
447
467
  const deltaY = e.clientY - this.lastMousePos.y
448
468
 
449
469
  if (this.mouseButton === 2) {
450
- // Right-click: pan the camera target
451
470
  this.panCamera(deltaX, deltaY)
452
- } else {
453
- // Left-click (or default): rotate the camera
471
+ } else if (this.orbitButton === "middle" ? this.mouseButton === 1 : this.mouseButton !== 2) {
454
472
  this.alpha += deltaX * this.angularSensitivity
455
473
  this.beta -= deltaY * this.angularSensitivity
456
-
457
474
  // Clamp beta to prevent flipping
458
475
  this.beta = Math.max(this.lowerBetaLimit, Math.min(this.upperBetaLimit, this.beta))
459
476
  }