@genex-ai/cli-demo 1.5.2-dev.398 → 1.6.0-dev.399
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +288 -10
- package/package.json +1 -1
- package/templates/asset-viewer/asset.config.json +25 -0
- package/templates/asset-viewer/genex-asset.example.json +222 -0
- package/templates/asset-viewer/index.html +200 -0
- package/templates/asset-viewer/package.json +24 -0
- package/templates/asset-viewer/public/fonts/Geist-variable.woff2 +0 -0
- package/templates/asset-viewer/public/fonts/GeistMono-variable.woff2 +0 -0
- package/templates/asset-viewer/shared-files.sha256.json +14 -0
- package/templates/asset-viewer/src/asset/PLACEHOLDER.ts +237 -0
- package/templates/asset-viewer/src/main.js +149 -0
- package/templates/asset-viewer/src/viewer/gates-overlay.js +521 -0
- package/templates/asset-viewer/src/viewer/hud.js +73 -0
- package/templates/asset-viewer/src/viewer/stage.js +760 -0
- package/templates/asset-viewer/tools/emit-manifest.mjs +653 -0
- package/templates/asset-viewer/tools/gates.mjs +682 -0
- package/templates/asset-viewer/tools/stamp-manifest.mjs +134 -0
- package/templates/asset-viewer/vite.config.js +24 -0
- package/templates/skills/genex-asset-author/SKILL.md +101 -0
- package/templates/skills/genex-game-director/references/routing-map.md +1 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
// The neutral stage every asset is shown on: renderer, camera, environment,
|
|
2
|
+
// lights, ground shadow and the turntable. Deliberately no floor, no grid, no
|
|
3
|
+
// scale figure.
|
|
4
|
+
//
|
|
5
|
+
// The library is not only floor-standing props. A wall bracket, a ceiling lamp
|
|
6
|
+
// or a hand tool standing on a ground plane reads as a mistake rather than as a
|
|
7
|
+
// stage, and a grid that is right for a pallet is wrong for the next asset in.
|
|
8
|
+
// What replaces the floor is a LIT BACKDROP plus a soft ground shadow that has
|
|
9
|
+
// no edges of its own - both are presentation that is never wrong for whatever
|
|
10
|
+
// it is showing, and the HUD already states the real size in metres.
|
|
11
|
+
//
|
|
12
|
+
// This is the file that makes the library look like one product, which is why it
|
|
13
|
+
// is copied verbatim and hashed by gate G13. Edit it in
|
|
14
|
+
// packages/asset-viewer-template/template/src/viewer, never in an asset folder.
|
|
15
|
+
|
|
16
|
+
import * as THREE from "three";
|
|
17
|
+
|
|
18
|
+
const TURNTABLE_PERIOD_S = 12;
|
|
19
|
+
const MAX_DPR = 1.5;
|
|
20
|
+
|
|
21
|
+
// --- Backdrop --------------------------------------------------------------
|
|
22
|
+
// A pool of light behind the subject, falling off to near-black in the corners.
|
|
23
|
+
//
|
|
24
|
+
// The flat grey it replaces was measured (L* 32.5) as the one value that kept
|
|
25
|
+
// every asset's outline above the just-noticeable difference - a flat backdrop
|
|
26
|
+
// has to serve the darkest asset and the lightest one with a single number, and
|
|
27
|
+
// at L* 20.6 a third of the wheelie bin's outline disappeared into it.
|
|
28
|
+
//
|
|
29
|
+
// A pool removes that compromise instead of re-tuning it. The subject is
|
|
30
|
+
// silhouetted against POOL, which lands within a couple of points of that
|
|
31
|
+
// measured-safe grey, so the outline separation it was chosen for still holds;
|
|
32
|
+
// the darkness lives out at the corners, where no asset has any geometry. Both
|
|
33
|
+
// values are final sRGB - the backdrop is chrome, not lit geometry, so it is
|
|
34
|
+
// written straight to the output buffer with no tone mapping and no colour
|
|
35
|
+
// conversion applied to it.
|
|
36
|
+
const POOL = [0x34 / 255, 0x39 / 255, 0x40 / 255];
|
|
37
|
+
const CORNER = [0x16 / 255, 0x18 / 255, 0x1d / 255];
|
|
38
|
+
// The pool sits above centre: the camera aims a little above the centroid, so
|
|
39
|
+
// this puts the brightest part of the backdrop behind the subject's mass.
|
|
40
|
+
const POOL_CENTRE_Y = 0.56;
|
|
41
|
+
|
|
42
|
+
const BACKDROP_VERT = /* glsl */ `
|
|
43
|
+
varying vec2 vUv;
|
|
44
|
+
void main() {
|
|
45
|
+
vUv = uv;
|
|
46
|
+
gl_Position = vec4(position.xy, 0.0, 1.0);
|
|
47
|
+
}
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
// The dither is not decoration. A pool this wide crosses ~60 8-bit steps over
|
|
51
|
+
// ~900 px, which is textbook Mach banding; one pixel of ordered noise costs
|
|
52
|
+
// nothing and removes every ring.
|
|
53
|
+
const BACKDROP_FRAG = /* glsl */ `
|
|
54
|
+
precision highp float;
|
|
55
|
+
varying vec2 vUv;
|
|
56
|
+
uniform vec3 uPool;
|
|
57
|
+
uniform vec3 uCorner;
|
|
58
|
+
uniform float uAspect;
|
|
59
|
+
|
|
60
|
+
void main() {
|
|
61
|
+
vec2 p = vUv - vec2(0.5, ${POOL_CENTRE_Y.toFixed(2)});
|
|
62
|
+
p.x *= uAspect;
|
|
63
|
+
float d = length(p);
|
|
64
|
+
float pool = smoothstep(0.92, 0.02, d);
|
|
65
|
+
// The exponent is the pool's focus, and it is the ONE knob here that softens
|
|
66
|
+
// the light without lifting the corners. Widening the smoothstep would blur
|
|
67
|
+
// it too, but the outer edge is what holds the corners at CORNER - push that
|
|
68
|
+
// out and the darkness goes with it. Lowering the exponent spreads brightness
|
|
69
|
+
// through the mid-field while d >= 0.92 still lands on exactly zero.
|
|
70
|
+
pool = pow(pool, 1.08);
|
|
71
|
+
vec3 col = mix(uCorner, uPool, pool);
|
|
72
|
+
float n = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453);
|
|
73
|
+
col += (n - 0.5) / 255.0;
|
|
74
|
+
gl_FragColor = vec4(col, 1.0);
|
|
75
|
+
}
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
function createBackdrop() {
|
|
79
|
+
const scene = new THREE.Scene();
|
|
80
|
+
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
81
|
+
const material = new THREE.ShaderMaterial({
|
|
82
|
+
vertexShader: BACKDROP_VERT,
|
|
83
|
+
fragmentShader: BACKDROP_FRAG,
|
|
84
|
+
uniforms: {
|
|
85
|
+
uPool: { value: new THREE.Vector3(...POOL) },
|
|
86
|
+
uCorner: { value: new THREE.Vector3(...CORNER) },
|
|
87
|
+
uAspect: { value: 1 },
|
|
88
|
+
},
|
|
89
|
+
depthTest: false,
|
|
90
|
+
depthWrite: false,
|
|
91
|
+
});
|
|
92
|
+
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material);
|
|
93
|
+
mesh.frustumCulled = false;
|
|
94
|
+
scene.add(mesh);
|
|
95
|
+
return { scene, camera, material, mesh };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- Environment -----------------------------------------------------------
|
|
99
|
+
// Indirect light, which is the half of a real room that a directional light
|
|
100
|
+
// cannot fake. Without it a metal reads as flat grey paint - metalness 1.0 has
|
|
101
|
+
// no diffuse term at all, so with only analytic lights it has nothing to
|
|
102
|
+
// reflect and renders near-black - and every unlit face is a single ambient
|
|
103
|
+
// constant, which is exactly what made the old turntable's dark side look like
|
|
104
|
+
// a cut-out.
|
|
105
|
+
//
|
|
106
|
+
// Built procedurally out of coloured panels and pre-filtered with PMREM, so the
|
|
107
|
+
// viewer still ships no HDRI and still loads no external file: the same rule the
|
|
108
|
+
// asset modules live under, applied to their host.
|
|
109
|
+
//
|
|
110
|
+
// Panel colours are set with setRGB, which is working (linear) space, NOT
|
|
111
|
+
// setHex, which converts from sRGB - these are light intensities above 1.0 and
|
|
112
|
+
// converting them would clamp the whole point of the environment away.
|
|
113
|
+
const ENV_PANELS = [
|
|
114
|
+
// [x, y, z, w, h, d, r, g, b] - a soft box key upper front-right, a broad
|
|
115
|
+
// cool fill to the left, a warm bounce low and front (the floor light a room
|
|
116
|
+
// has and this stage does not), and a dim rim behind.
|
|
117
|
+
[3.4, 3.0, 3.0, 4.2, 3.0, 0.1, 3.4, 3.2, 2.9],
|
|
118
|
+
[-4.0, 1.8, 1.4, 0.1, 4.0, 5.0, 0.9, 1.05, 1.35],
|
|
119
|
+
[0.0, -2.4, 2.6, 6.0, 0.1, 4.0, 0.62, 0.55, 0.48],
|
|
120
|
+
[0.0, 2.2, -4.2, 5.0, 3.0, 0.1, 0.5, 0.58, 0.72],
|
|
121
|
+
[0.0, 4.4, 0.0, 5.0, 0.1, 5.0, 0.85, 0.9, 1.0],
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
function buildEnvironment(renderer) {
|
|
125
|
+
const scene = new THREE.Scene();
|
|
126
|
+
const disposables = [];
|
|
127
|
+
|
|
128
|
+
const shellGeo = new THREE.BoxGeometry(14, 10, 14);
|
|
129
|
+
const shellMat = new THREE.MeshBasicMaterial({ side: THREE.BackSide });
|
|
130
|
+
shellMat.color.setRGB(0.055, 0.06, 0.072);
|
|
131
|
+
scene.add(new THREE.Mesh(shellGeo, shellMat));
|
|
132
|
+
disposables.push(shellGeo, shellMat);
|
|
133
|
+
|
|
134
|
+
for (const [x, y, z, w, h, d, r, g, b] of ENV_PANELS) {
|
|
135
|
+
const geo = new THREE.BoxGeometry(w, h, d);
|
|
136
|
+
const mat = new THREE.MeshBasicMaterial();
|
|
137
|
+
mat.color.setRGB(r, g, b);
|
|
138
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
139
|
+
mesh.position.set(x, y, z);
|
|
140
|
+
scene.add(mesh);
|
|
141
|
+
disposables.push(geo, mat);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const pmrem = new THREE.PMREMGenerator(renderer);
|
|
145
|
+
pmrem.compileEquirectangularShader();
|
|
146
|
+
const target = pmrem.fromScene(scene, 0.02);
|
|
147
|
+
pmrem.dispose();
|
|
148
|
+
for (const d of disposables) d.dispose();
|
|
149
|
+
return target.texture;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- Ground shadow ---------------------------------------------------------
|
|
153
|
+
// A soft contact shadow, rendered once per fit into a texture and shown on a
|
|
154
|
+
// plane at the asset's base.
|
|
155
|
+
//
|
|
156
|
+
// A shadow-mapped floor was the obvious alternative and is the wrong one here:
|
|
157
|
+
// a floor plane has EDGES, and the moment the camera drops toward the horizon
|
|
158
|
+
// the stage stops being a backdrop and becomes a room with a visible tabletop
|
|
159
|
+
// in it. This has no edges - the darkness only exists where the asset is, and
|
|
160
|
+
// fades to nothing well inside the plane, so a wall bracket gets a drop shadow
|
|
161
|
+
// rather than a floor.
|
|
162
|
+
const SHADOW_RES = 512;
|
|
163
|
+
const SHADOW_STRENGTH = 0.74;
|
|
164
|
+
// Two separable passes at falling radii, in texels. The first cut ran one pass
|
|
165
|
+
// at 1.6 texels - about a centimetre across a metre-wide plane - which is a blur
|
|
166
|
+
// nobody can see.
|
|
167
|
+
const SHADOW_BLUR_TEXELS = [30, 12];
|
|
168
|
+
// How much room to leave around the shadow's own bounds, as a fraction of the
|
|
169
|
+
// footprint, so the blur has somewhere to spill and its soft edge is never cut
|
|
170
|
+
// off square.
|
|
171
|
+
const SHADOW_PAD = 0.26;
|
|
172
|
+
// The elevation the ground shadow is CAST from - deliberately steeper than the
|
|
173
|
+
// 35-degree key, and this is the number that decides whether the shadow exists
|
|
174
|
+
// as far as a viewer is concerned.
|
|
175
|
+
//
|
|
176
|
+
// Straight down (the first cut) is the honest ambient-occlusion answer and it is
|
|
177
|
+
// invisible: the shadow lands exactly under the asset's own footprint, so the
|
|
178
|
+
// asset covers every pixel of it from every angle that can see the ground at
|
|
179
|
+
// all. Measured on the espresso machine - a correct, well-formed shadow that
|
|
180
|
+
// nothing could ever see. At the key's own 35 degrees the shadow stretches 1.4x
|
|
181
|
+
// the asset's height and the stage turns into a room with a floor in it. 52
|
|
182
|
+
// throws it about 0.8x the height, which clears the silhouette and stays a pool
|
|
183
|
+
// rather than becoming scenery.
|
|
184
|
+
const SHADOW_ELEVATION_DEG = 52;
|
|
185
|
+
|
|
186
|
+
// Where the key stands, and it is a presentation decision rather than a detail.
|
|
187
|
+
//
|
|
188
|
+
// The old value put the key at the SAME azimuth as the camera's home angle -
|
|
189
|
+
// the light sitting exactly behind the viewer's head. That is the one placement
|
|
190
|
+
// that carves no form at all (every surface the camera can see is lit straight
|
|
191
|
+
// on) and it is also the one placement whose shadow is invisible, because the
|
|
192
|
+
// shadow falls along the view axis and hides behind the asset that cast it.
|
|
193
|
+
// Both symptoms, one cause. 45 degrees off the camera is the ordinary studio
|
|
194
|
+
// three-quarter key: it models the form, and it throws the shadow out to the
|
|
195
|
+
// side where it can be seen.
|
|
196
|
+
const KEY_AZIMUTH_DEG = 0;
|
|
197
|
+
const KEY_ELEVATION_DEG = 35;
|
|
198
|
+
|
|
199
|
+
// Every vertex is slid down its own light ray onto the ground plane, which is
|
|
200
|
+
// the whole trick: the pass is an ordinary render of the asset, but the asset
|
|
201
|
+
// arrives already flattened into the shape of its own shadow.
|
|
202
|
+
const SHADOW_CAST_VERT = /* glsl */ `
|
|
203
|
+
varying float vHeight;
|
|
204
|
+
uniform float uBaseY;
|
|
205
|
+
uniform vec2 uSlide;
|
|
206
|
+
void main() {
|
|
207
|
+
vec4 world = modelMatrix * vec4(position, 1.0);
|
|
208
|
+
vHeight = max(world.y - uBaseY, 0.0);
|
|
209
|
+
world.xz += uSlide * vHeight;
|
|
210
|
+
world.y = uBaseY;
|
|
211
|
+
gl_Position = projectionMatrix * viewMatrix * world;
|
|
212
|
+
}
|
|
213
|
+
`;
|
|
214
|
+
// The floor term is the difference between this working on every asset and
|
|
215
|
+
// working on the ones that happen to be closed solids. Height alone says "the
|
|
216
|
+
// nearest surface above this point is high up, so barely shade it" - but a prop
|
|
217
|
+
// modelled as an open shell has NO underside, so the nearest surface above its
|
|
218
|
+
// own footprint is the inside of its lid, and it casts almost nothing. Measured
|
|
219
|
+
// on the espresso machine: alpha 0.08 under the middle of a solid-looking body.
|
|
220
|
+
// Any occluder at all now shades to at least SHADOW_FLOOR; height decides how
|
|
221
|
+
// much darker it gets from there.
|
|
222
|
+
const SHADOW_FLOOR = 0.42;
|
|
223
|
+
|
|
224
|
+
const SHADOW_CAST_FRAG = /* glsl */ `
|
|
225
|
+
precision highp float;
|
|
226
|
+
varying float vHeight;
|
|
227
|
+
uniform float uFalloff;
|
|
228
|
+
void main() {
|
|
229
|
+
float a = 1.0 - clamp(vHeight / uFalloff, 0.0, 1.0);
|
|
230
|
+
float shade = ${SHADOW_FLOOR.toFixed(2)} + ${(1 - SHADOW_FLOOR).toFixed(2)} * pow(a, 1.5);
|
|
231
|
+
gl_FragColor = vec4(vec3(1.0 - shade), 1.0);
|
|
232
|
+
}
|
|
233
|
+
`;
|
|
234
|
+
|
|
235
|
+
// Fullscreen: the blur passes are screen-space and want NDC straight out.
|
|
236
|
+
const BLUR_VERT = /* glsl */ `
|
|
237
|
+
varying vec2 vUv;
|
|
238
|
+
void main() {
|
|
239
|
+
vUv = uv;
|
|
240
|
+
gl_Position = vec4(position.xy, 0.0, 1.0);
|
|
241
|
+
}
|
|
242
|
+
`;
|
|
243
|
+
|
|
244
|
+
// The display plane is a real object in the world and needs the real transform.
|
|
245
|
+
// Sharing BLUR_VERT with it - which is what the first cut did - draws the shadow
|
|
246
|
+
// as a screen-space rectangle instead, and since it lands in front of the asset
|
|
247
|
+
// at NDC depth 0 the failure does not look like a missing shadow at all.
|
|
248
|
+
const PLANE_VERT = /* glsl */ `
|
|
249
|
+
varying vec2 vUv;
|
|
250
|
+
void main() {
|
|
251
|
+
vUv = uv;
|
|
252
|
+
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
253
|
+
}
|
|
254
|
+
`;
|
|
255
|
+
|
|
256
|
+
const BLUR_FRAG = /* glsl */ `
|
|
257
|
+
precision highp float;
|
|
258
|
+
varying vec2 vUv;
|
|
259
|
+
uniform sampler2D uMap;
|
|
260
|
+
uniform vec2 uStep;
|
|
261
|
+
void main() {
|
|
262
|
+
float sum = 0.0;
|
|
263
|
+
sum += texture2D(uMap, vUv - uStep * 4.0).r * 0.051;
|
|
264
|
+
sum += texture2D(uMap, vUv - uStep * 3.0).r * 0.0918;
|
|
265
|
+
sum += texture2D(uMap, vUv - uStep * 2.0).r * 0.12245;
|
|
266
|
+
sum += texture2D(uMap, vUv - uStep * 1.0).r * 0.1531;
|
|
267
|
+
sum += texture2D(uMap, vUv).r * 0.1633;
|
|
268
|
+
sum += texture2D(uMap, vUv + uStep * 1.0).r * 0.1531;
|
|
269
|
+
sum += texture2D(uMap, vUv + uStep * 2.0).r * 0.12245;
|
|
270
|
+
sum += texture2D(uMap, vUv + uStep * 3.0).r * 0.0918;
|
|
271
|
+
sum += texture2D(uMap, vUv + uStep * 4.0).r * 0.051;
|
|
272
|
+
gl_FragColor = vec4(vec3(sum), 1.0);
|
|
273
|
+
}
|
|
274
|
+
`;
|
|
275
|
+
|
|
276
|
+
const SHADOW_SHOW_FRAG = /* glsl */ `
|
|
277
|
+
precision highp float;
|
|
278
|
+
varying vec2 vUv;
|
|
279
|
+
uniform sampler2D uMap;
|
|
280
|
+
uniform float uStrength;
|
|
281
|
+
void main() {
|
|
282
|
+
float shade = 1.0 - texture2D(uMap, vUv).r;
|
|
283
|
+
// Fade the plane out before its own border, so the quad's edge can never be
|
|
284
|
+
// what the eye finds. Radial, not per-axis: a square fade leaves four faint
|
|
285
|
+
// corners that read as a sheet of paper under the asset.
|
|
286
|
+
float edge = 1.0 - smoothstep(0.80, 1.0, length(vUv - 0.5) * 2.0);
|
|
287
|
+
gl_FragColor = vec4(0.0, 0.0, 0.0, shade * uStrength * edge);
|
|
288
|
+
}
|
|
289
|
+
`;
|
|
290
|
+
|
|
291
|
+
function createGroundShadow() {
|
|
292
|
+
const options = { depthBuffer: true, generateMipmaps: false };
|
|
293
|
+
const renderTarget = new THREE.WebGLRenderTarget(SHADOW_RES, SHADOW_RES, options);
|
|
294
|
+
const blurTarget = new THREE.WebGLRenderTarget(SHADOW_RES, SHADOW_RES, options);
|
|
295
|
+
|
|
296
|
+
// Looks DOWN, one metre above the ground plane. Down rather than up because
|
|
297
|
+
// the display plane's own UVs run that way: an up-looking camera mirrors the
|
|
298
|
+
// texture in Z, which a symmetric blob hides completely and an offset shadow
|
|
299
|
+
// shows immediately by landing on the wrong side of the asset.
|
|
300
|
+
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.5, 1.5);
|
|
301
|
+
camera.rotation.x = -Math.PI / 2;
|
|
302
|
+
|
|
303
|
+
const castMaterial = new THREE.ShaderMaterial({
|
|
304
|
+
vertexShader: SHADOW_CAST_VERT,
|
|
305
|
+
fragmentShader: SHADOW_CAST_FRAG,
|
|
306
|
+
uniforms: { uBaseY: { value: 0 }, uFalloff: { value: 1 }, uSlide: { value: new THREE.Vector2() } },
|
|
307
|
+
side: THREE.DoubleSide,
|
|
308
|
+
// Everything lands on one plane, so there is no depth order to resolve -
|
|
309
|
+
// what is wanted is the DARKEST contribution over each point, which is a min
|
|
310
|
+
// blend. Ordinary alpha blending would instead stack every overlapping part
|
|
311
|
+
// and turn a lattice into a solid.
|
|
312
|
+
depthTest: false,
|
|
313
|
+
depthWrite: false,
|
|
314
|
+
blending: THREE.CustomBlending,
|
|
315
|
+
blendEquation: THREE.MinEquation,
|
|
316
|
+
blendSrc: THREE.OneFactor,
|
|
317
|
+
blendDst: THREE.OneFactor,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const blurScene = new THREE.Scene();
|
|
321
|
+
const blurCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
322
|
+
const blurMaterial = new THREE.ShaderMaterial({
|
|
323
|
+
vertexShader: BLUR_VERT,
|
|
324
|
+
fragmentShader: BLUR_FRAG,
|
|
325
|
+
uniforms: { uMap: { value: null }, uStep: { value: new THREE.Vector2() } },
|
|
326
|
+
depthTest: false,
|
|
327
|
+
depthWrite: false,
|
|
328
|
+
});
|
|
329
|
+
const blurQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), blurMaterial);
|
|
330
|
+
blurQuad.frustumCulled = false;
|
|
331
|
+
blurScene.add(blurQuad);
|
|
332
|
+
|
|
333
|
+
const showMaterial = new THREE.ShaderMaterial({
|
|
334
|
+
vertexShader: PLANE_VERT,
|
|
335
|
+
fragmentShader: SHADOW_SHOW_FRAG,
|
|
336
|
+
uniforms: { uMap: { value: blurTarget.texture }, uStrength: { value: SHADOW_STRENGTH } },
|
|
337
|
+
transparent: true,
|
|
338
|
+
depthWrite: false,
|
|
339
|
+
});
|
|
340
|
+
const plane = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), showMaterial);
|
|
341
|
+
plane.rotation.x = -Math.PI / 2;
|
|
342
|
+
plane.renderOrder = -1;
|
|
343
|
+
|
|
344
|
+
return { renderTarget, blurTarget, camera, castMaterial, blurScene, blurCamera, blurMaterial, blurQuad, plane };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* @param {{ container: HTMLElement, deterministic?: boolean, groundShadow?: boolean }} opts
|
|
349
|
+
*
|
|
350
|
+
* groundShadow is the one presentation switch an asset owns, and it exists
|
|
351
|
+
* because there is no geometric way to earn it: every asset's lowest point is
|
|
352
|
+
* its own bounding box floor, so a chandelier "stands on" the ground exactly as
|
|
353
|
+
* much as a table does. Left on, a hanging or wall-mounted asset gets a smear of
|
|
354
|
+
* shadow floating in mid-air under it, which reads as dirt on the lens.
|
|
355
|
+
*/
|
|
356
|
+
export function createStage({ container, deterministic = false, groundShadow = true }) {
|
|
357
|
+
const renderer = new THREE.WebGLRenderer({
|
|
358
|
+
antialias: true,
|
|
359
|
+
// The gate runner reads pixels back after the frame; three's default
|
|
360
|
+
// discards the buffer on present.
|
|
361
|
+
preserveDrawingBuffer: deterministic,
|
|
362
|
+
});
|
|
363
|
+
renderer.setPixelRatio(deterministic ? 1 : Math.min(globalThis.devicePixelRatio || 1, MAX_DPR));
|
|
364
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
365
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
366
|
+
// Down from 1.0. The environment is a whole extra light source on top of what
|
|
367
|
+
// the old preset already had, and left at 1.0 the first pass blew the espresso
|
|
368
|
+
// machine's brushed steel to near-white - the roughness map was still there,
|
|
369
|
+
// it was just above the point where the highlight rolls off.
|
|
370
|
+
renderer.toneMappingExposure = 0.88;
|
|
371
|
+
// Shadows are on. The old preset had none because there was no floor for one
|
|
372
|
+
// to fall on, but that argument only ever covered the CAST shadow: with a real
|
|
373
|
+
// environment doing the ambient, self-shadowing is what tells an overhang from
|
|
374
|
+
// a recess, and the ground shadow below has no floor in it.
|
|
375
|
+
renderer.shadowMap.enabled = true;
|
|
376
|
+
// PCF, not PCFSoft: three deprecated PCFSoftShadowMap and now warns on it,
|
|
377
|
+
// which G7 counts as a failure. No loss here - the soft half of the look is
|
|
378
|
+
// the ground shadow below, and what the map is for is self-shadowing, where
|
|
379
|
+
// a crisp terminator is the point.
|
|
380
|
+
renderer.shadowMap.type = THREE.PCFShadowMap;
|
|
381
|
+
container.appendChild(renderer.domElement);
|
|
382
|
+
|
|
383
|
+
const scene = new THREE.Scene();
|
|
384
|
+
scene.background = null;
|
|
385
|
+
scene.environment = buildEnvironment(renderer);
|
|
386
|
+
scene.environmentIntensity = 0.78;
|
|
387
|
+
|
|
388
|
+
const backdrop = createBackdrop();
|
|
389
|
+
const ground = groundShadow ? createGroundShadow() : null;
|
|
390
|
+
|
|
391
|
+
const camera = new THREE.PerspectiveCamera(38, 1, 0.02, 200);
|
|
392
|
+
|
|
393
|
+
// The environment is now the PRIMARY light and the analytic lights are down
|
|
394
|
+
// hard from what they were (key 2.2 → 1.25, fill 0.85 → 0.2). The old preset
|
|
395
|
+
// had to carry the whole image on three lamps; this only needs a key sharp
|
|
396
|
+
// enough to cast and to put one specular edge on a curve.
|
|
397
|
+
//
|
|
398
|
+
// The hemisphere light is gone rather than reduced. It was one flat constant
|
|
399
|
+
// standing in for a room, and the environment has a room in it - a top panel,
|
|
400
|
+
// a floor bounce and a cool wall - so keeping both would just be adding the
|
|
401
|
+
// approximation back on top of the thing that replaced it.
|
|
402
|
+
const key = new THREE.DirectionalLight(0xfff3e2, 1.65);
|
|
403
|
+
key.castShadow = true;
|
|
404
|
+
key.shadow.mapSize.set(2048, 2048);
|
|
405
|
+
scene.add(key);
|
|
406
|
+
scene.add(key.target);
|
|
407
|
+
|
|
408
|
+
const fill = new THREE.DirectionalLight(0xe4eaf2, 0.2);
|
|
409
|
+
scene.add(fill);
|
|
410
|
+
|
|
411
|
+
// Everything the VIEWER draws that is not the asset. Hidden while gate G2
|
|
412
|
+
// counts draw calls, so the stage never spends the asset's budget.
|
|
413
|
+
const chrome = new THREE.Group();
|
|
414
|
+
chrome.name = "viewer-chrome";
|
|
415
|
+
if (ground) chrome.add(ground.plane);
|
|
416
|
+
scene.add(chrome);
|
|
417
|
+
|
|
418
|
+
const assetRoot = new THREE.Group();
|
|
419
|
+
assetRoot.name = "asset-root";
|
|
420
|
+
scene.add(assetRoot);
|
|
421
|
+
|
|
422
|
+
const orbit = createOrbit(camera, renderer.domElement, () => {
|
|
423
|
+
dirty = true;
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
let turntable = !deterministic;
|
|
427
|
+
let dirty = true;
|
|
428
|
+
let running = false;
|
|
429
|
+
let last = 0;
|
|
430
|
+
|
|
431
|
+
function resize() {
|
|
432
|
+
const w = container.clientWidth || 1;
|
|
433
|
+
const h = container.clientHeight || 1;
|
|
434
|
+
renderer.setSize(w, h, false);
|
|
435
|
+
camera.aspect = w / h;
|
|
436
|
+
camera.updateProjectionMatrix();
|
|
437
|
+
backdrop.material.uniforms.uAspect.value = w / h;
|
|
438
|
+
dirty = true;
|
|
439
|
+
}
|
|
440
|
+
const resizeObserver = new ResizeObserver(resize);
|
|
441
|
+
resizeObserver.observe(container);
|
|
442
|
+
resize();
|
|
443
|
+
|
|
444
|
+
function placeLights(box) {
|
|
445
|
+
const size = box.getSize(new THREE.Vector3());
|
|
446
|
+
const centre = box.getCenter(new THREE.Vector3());
|
|
447
|
+
const r = Math.max(size.length() * 0.6, 0.5);
|
|
448
|
+
const az = THREE.MathUtils.degToRad(KEY_AZIMUTH_DEG);
|
|
449
|
+
const el = THREE.MathUtils.degToRad(KEY_ELEVATION_DEG);
|
|
450
|
+
key.position.set(
|
|
451
|
+
centre.x + Math.cos(el) * Math.cos(az) * r * 4,
|
|
452
|
+
centre.y + Math.sin(el) * r * 4,
|
|
453
|
+
centre.z + Math.cos(el) * Math.sin(az) * r * 4,
|
|
454
|
+
);
|
|
455
|
+
key.target.position.copy(centre);
|
|
456
|
+
key.target.updateMatrixWorld();
|
|
457
|
+
fill.position.set(centre.x - r * 3, centre.y + r * 1.6, centre.z - r * 3.4);
|
|
458
|
+
|
|
459
|
+
// Fit the shadow camera to the asset instead of leaving it at three's 5-unit
|
|
460
|
+
// default: a 0.36 m traffic cone inside a 10 m frustum spends its whole 2048
|
|
461
|
+
// map on empty space and comes out as a staircase.
|
|
462
|
+
const extent = Math.max(size.length() * 0.62, 0.05);
|
|
463
|
+
const cam = key.shadow.camera;
|
|
464
|
+
cam.left = -extent;
|
|
465
|
+
cam.right = extent;
|
|
466
|
+
cam.top = extent;
|
|
467
|
+
cam.bottom = -extent;
|
|
468
|
+
cam.near = Math.max(r * 4 - extent * 2, 0.01);
|
|
469
|
+
cam.far = r * 8 + extent * 2;
|
|
470
|
+
cam.updateProjectionMatrix();
|
|
471
|
+
// Both biases scale with the asset: a constant that stops acne on a 3.6 m
|
|
472
|
+
// lantern peels the shadow clean off a 0.36 m cone.
|
|
473
|
+
key.shadow.bias = -0.00035 * Math.max(extent, 0.2);
|
|
474
|
+
key.shadow.normalBias = extent * 0.012;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Re-render the ground shadow. Cheap, and only on fit/explode - the
|
|
478
|
+
* turntable moves the CAMERA, so the shadow itself never changes. */
|
|
479
|
+
function bakeGroundShadow(box) {
|
|
480
|
+
if (!ground) return;
|
|
481
|
+
const size = box.getSize(new THREE.Vector3());
|
|
482
|
+
const centre = box.getCenter(new THREE.Vector3());
|
|
483
|
+
const baseY = box.min.y;
|
|
484
|
+
const falloff = Math.max(Math.min(size.y, Math.max(size.x, size.z)) * 0.9, 0.05);
|
|
485
|
+
|
|
486
|
+
// Where the top of the asset's shadow lands, and therefore how much ground
|
|
487
|
+
// the bake has to cover.
|
|
488
|
+
const el = THREE.MathUtils.degToRad(SHADOW_ELEVATION_DEG);
|
|
489
|
+
const az = THREE.MathUtils.degToRad(KEY_AZIMUTH_DEG);
|
|
490
|
+
const cot = Math.cos(el) / Math.sin(el);
|
|
491
|
+
const slideX = -Math.cos(az) * cot;
|
|
492
|
+
const slideZ = -Math.sin(az) * cot;
|
|
493
|
+
const reachX = slideX * size.y;
|
|
494
|
+
const reachZ = slideZ * size.y;
|
|
495
|
+
|
|
496
|
+
const pad = Math.max(size.x, size.z) * SHADOW_PAD;
|
|
497
|
+
const minX = Math.min(box.min.x, box.min.x + reachX) - pad;
|
|
498
|
+
const maxX = Math.max(box.max.x, box.max.x + reachX) + pad;
|
|
499
|
+
const minZ = Math.min(box.min.z, box.min.z + reachZ) - pad;
|
|
500
|
+
const maxZ = Math.max(box.max.z, box.max.z + reachZ) + pad;
|
|
501
|
+
// One square extent, so the texture's texels stay square and the two blur
|
|
502
|
+
// radii mean the same distance along both axes.
|
|
503
|
+
const half = Math.max(maxX - minX, maxZ - minZ) / 2 || 0.5;
|
|
504
|
+
const shadowCentre = new THREE.Vector3((minX + maxX) / 2, baseY, (minZ + maxZ) / 2);
|
|
505
|
+
|
|
506
|
+
ground.camera.left = -half;
|
|
507
|
+
ground.camera.right = half;
|
|
508
|
+
ground.camera.top = half;
|
|
509
|
+
ground.camera.bottom = -half;
|
|
510
|
+
ground.camera.position.set(shadowCentre.x, baseY + 1, shadowCentre.z);
|
|
511
|
+
ground.camera.updateProjectionMatrix();
|
|
512
|
+
ground.camera.updateMatrixWorld();
|
|
513
|
+
|
|
514
|
+
ground.castMaterial.uniforms.uBaseY.value = baseY;
|
|
515
|
+
ground.castMaterial.uniforms.uFalloff.value = falloff;
|
|
516
|
+
ground.castMaterial.uniforms.uSlide.value.set(slideX, slideZ);
|
|
517
|
+
|
|
518
|
+
const previousTarget = renderer.getRenderTarget();
|
|
519
|
+
const chromeWasVisible = chrome.visible;
|
|
520
|
+
const shadowsWereOn = renderer.shadowMap.enabled;
|
|
521
|
+
chrome.visible = false;
|
|
522
|
+
renderer.shadowMap.enabled = false;
|
|
523
|
+
scene.overrideMaterial = ground.castMaterial;
|
|
524
|
+
const clear = new THREE.Color(0xffffff);
|
|
525
|
+
const previousClear = renderer.getClearColor(new THREE.Color());
|
|
526
|
+
const previousAlpha = renderer.getClearAlpha();
|
|
527
|
+
renderer.setClearColor(clear, 1);
|
|
528
|
+
renderer.setRenderTarget(ground.renderTarget);
|
|
529
|
+
renderer.clear();
|
|
530
|
+
renderer.render(scene, ground.camera);
|
|
531
|
+
scene.overrideMaterial = null;
|
|
532
|
+
|
|
533
|
+
// Separable blur, ping-ponging between the two targets. Each radius is one
|
|
534
|
+
// horizontal pass and one vertical, and every pair leaves the result back in
|
|
535
|
+
// renderTarget - so however many radii the list holds, the display plane
|
|
536
|
+
// always samples the same texture.
|
|
537
|
+
for (const texels of SHADOW_BLUR_TEXELS) {
|
|
538
|
+
const step = texels / 4 / SHADOW_RES; // the kernel reaches +-4 steps
|
|
539
|
+
ground.blurMaterial.uniforms.uMap.value = ground.renderTarget.texture;
|
|
540
|
+
ground.blurMaterial.uniforms.uStep.value.set(step, 0);
|
|
541
|
+
renderer.setRenderTarget(ground.blurTarget);
|
|
542
|
+
renderer.render(ground.blurScene, ground.blurCamera);
|
|
543
|
+
|
|
544
|
+
ground.blurMaterial.uniforms.uMap.value = ground.blurTarget.texture;
|
|
545
|
+
ground.blurMaterial.uniforms.uStep.value.set(0, step);
|
|
546
|
+
renderer.setRenderTarget(ground.renderTarget);
|
|
547
|
+
renderer.render(ground.blurScene, ground.blurCamera);
|
|
548
|
+
}
|
|
549
|
+
ground.blurMaterial.uniforms.uMap.value = null;
|
|
550
|
+
ground.plane.material.uniforms.uMap.value = ground.renderTarget.texture;
|
|
551
|
+
|
|
552
|
+
renderer.setRenderTarget(previousTarget);
|
|
553
|
+
renderer.setClearColor(previousClear, previousAlpha);
|
|
554
|
+
renderer.shadowMap.enabled = shadowsWereOn;
|
|
555
|
+
chrome.visible = chromeWasVisible;
|
|
556
|
+
|
|
557
|
+
ground.plane.scale.set(half * 2, half * 2, 1);
|
|
558
|
+
ground.plane.position.set(shadowCentre.x, baseY + Math.max(size.y, 0.1) * 0.0015, shadowCentre.z);
|
|
559
|
+
ground.plane.updateMatrixWorld();
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** One frame: the backdrop first, then the scene over it. */
|
|
563
|
+
function draw() {
|
|
564
|
+
// autoClear is restored immediately: the gate passes render their own scenes
|
|
565
|
+
// into their own targets through this same renderer and rely on it.
|
|
566
|
+
renderer.autoClear = true;
|
|
567
|
+
backdrop.material.uniforms.uAspect.value = camera.aspect;
|
|
568
|
+
renderer.render(backdrop.scene, backdrop.camera);
|
|
569
|
+
renderer.autoClear = false;
|
|
570
|
+
renderer.render(scene, camera);
|
|
571
|
+
renderer.autoClear = true;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return {
|
|
575
|
+
renderer,
|
|
576
|
+
scene,
|
|
577
|
+
camera,
|
|
578
|
+
assetRoot,
|
|
579
|
+
chrome,
|
|
580
|
+
orbit,
|
|
581
|
+
|
|
582
|
+
/** Frame the camera, place the lights, bake the ground shadow. */
|
|
583
|
+
fit(box, mul = 1) {
|
|
584
|
+
// Shadows are the viewer's business, not the asset's - the modules already
|
|
585
|
+
// expose castShadow/receiveShadow for a game, and this turns them on for
|
|
586
|
+
// the presentation without the module having to know.
|
|
587
|
+
assetRoot.traverse((obj) => {
|
|
588
|
+
if (obj.isMesh) {
|
|
589
|
+
obj.castShadow = true;
|
|
590
|
+
obj.receiveShadow = true;
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
placeLights(box);
|
|
594
|
+
bakeGroundShadow(box);
|
|
595
|
+
orbit.frame(box, mul);
|
|
596
|
+
dirty = true;
|
|
597
|
+
},
|
|
598
|
+
|
|
599
|
+
/** Re-bake after the parts moved (explode), where the shadow really changed. */
|
|
600
|
+
refreshShadow(box) {
|
|
601
|
+
bakeGroundShadow(box);
|
|
602
|
+
dirty = true;
|
|
603
|
+
},
|
|
604
|
+
|
|
605
|
+
setTurntable(v) {
|
|
606
|
+
turntable = v;
|
|
607
|
+
dirty = true;
|
|
608
|
+
return turntable;
|
|
609
|
+
},
|
|
610
|
+
toggleTurntable() {
|
|
611
|
+
return this.setTurntable(!turntable);
|
|
612
|
+
},
|
|
613
|
+
get turntable() {
|
|
614
|
+
return turntable;
|
|
615
|
+
},
|
|
616
|
+
invalidate() {
|
|
617
|
+
dirty = true;
|
|
618
|
+
},
|
|
619
|
+
renderOnce() {
|
|
620
|
+
draw();
|
|
621
|
+
dirty = false;
|
|
622
|
+
},
|
|
623
|
+
start(onFrame) {
|
|
624
|
+
if (running) return;
|
|
625
|
+
running = true;
|
|
626
|
+
last = 0;
|
|
627
|
+
const tick = (t) => {
|
|
628
|
+
if (!running) return;
|
|
629
|
+
const dt = last ? Math.min((t - last) / 1000, 0.1) : 0;
|
|
630
|
+
last = t;
|
|
631
|
+
if (turntable && !orbit.dragging) {
|
|
632
|
+
orbit.spherical.theta += (Math.PI * 2 * dt) / TURNTABLE_PERIOD_S;
|
|
633
|
+
orbit.apply();
|
|
634
|
+
}
|
|
635
|
+
onFrame?.(dt);
|
|
636
|
+
// Render on demand: a still view costs nothing, which matters on a
|
|
637
|
+
// phone looking at a prop that is not moving.
|
|
638
|
+
if (dirty) {
|
|
639
|
+
draw();
|
|
640
|
+
dirty = false;
|
|
641
|
+
}
|
|
642
|
+
requestAnimationFrame(tick);
|
|
643
|
+
};
|
|
644
|
+
requestAnimationFrame(tick);
|
|
645
|
+
},
|
|
646
|
+
stop() {
|
|
647
|
+
running = false;
|
|
648
|
+
},
|
|
649
|
+
dispose() {
|
|
650
|
+
running = false;
|
|
651
|
+
resizeObserver.disconnect();
|
|
652
|
+
scene.environment?.dispose();
|
|
653
|
+
backdrop.mesh.geometry.dispose();
|
|
654
|
+
backdrop.material.dispose();
|
|
655
|
+
if (ground) {
|
|
656
|
+
ground.renderTarget.dispose();
|
|
657
|
+
ground.blurTarget.dispose();
|
|
658
|
+
ground.castMaterial.dispose();
|
|
659
|
+
ground.blurMaterial.dispose();
|
|
660
|
+
ground.blurQuad.geometry.dispose();
|
|
661
|
+
ground.plane.geometry.dispose();
|
|
662
|
+
ground.plane.material.dispose();
|
|
663
|
+
}
|
|
664
|
+
renderer.dispose();
|
|
665
|
+
},
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Camera orbit, hand-rolled so the viewer needs no three/examples addon -
|
|
671
|
+
* the same rule the asset modules live under, applied to their host.
|
|
672
|
+
*/
|
|
673
|
+
function createOrbit(camera, dom, onChange) {
|
|
674
|
+
const target = new THREE.Vector3();
|
|
675
|
+
const spherical = new THREE.Spherical(4, Math.PI * 0.38, Math.PI * 0.25);
|
|
676
|
+
const home = { radius: 4, phi: Math.PI * 0.38, theta: Math.PI * 0.25 };
|
|
677
|
+
let dragging = false;
|
|
678
|
+
let lastX = 0;
|
|
679
|
+
let lastY = 0;
|
|
680
|
+
let pointerId = null;
|
|
681
|
+
|
|
682
|
+
function apply() {
|
|
683
|
+
spherical.phi = THREE.MathUtils.clamp(spherical.phi, 0.08, Math.PI * 0.495);
|
|
684
|
+
spherical.radius = THREE.MathUtils.clamp(spherical.radius, home.radius * 0.25, home.radius * 6);
|
|
685
|
+
camera.position.setFromSpherical(spherical).add(target);
|
|
686
|
+
camera.lookAt(target);
|
|
687
|
+
onChange?.();
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
dom.addEventListener("pointerdown", (e) => {
|
|
691
|
+
if (pointerId !== null) return;
|
|
692
|
+
pointerId = e.pointerId;
|
|
693
|
+
dragging = true;
|
|
694
|
+
lastX = e.clientX;
|
|
695
|
+
lastY = e.clientY;
|
|
696
|
+
dom.setPointerCapture(e.pointerId);
|
|
697
|
+
});
|
|
698
|
+
dom.addEventListener("pointermove", (e) => {
|
|
699
|
+
if (!dragging || e.pointerId !== pointerId) return;
|
|
700
|
+
// Drag right turns the model to the right: one pan convention, everywhere.
|
|
701
|
+
spherical.theta -= (e.clientX - lastX) * 0.006;
|
|
702
|
+
spherical.phi -= (e.clientY - lastY) * 0.006;
|
|
703
|
+
lastX = e.clientX;
|
|
704
|
+
lastY = e.clientY;
|
|
705
|
+
apply();
|
|
706
|
+
});
|
|
707
|
+
const release = (e) => {
|
|
708
|
+
if (e.pointerId !== pointerId) return;
|
|
709
|
+
dragging = false;
|
|
710
|
+
pointerId = null;
|
|
711
|
+
};
|
|
712
|
+
dom.addEventListener("pointerup", release);
|
|
713
|
+
dom.addEventListener("pointercancel", release);
|
|
714
|
+
dom.addEventListener(
|
|
715
|
+
"wheel",
|
|
716
|
+
(e) => {
|
|
717
|
+
e.preventDefault();
|
|
718
|
+
spherical.radius *= Math.exp(e.deltaY * 0.0012);
|
|
719
|
+
apply();
|
|
720
|
+
},
|
|
721
|
+
{ passive: false },
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
return {
|
|
725
|
+
get dragging() {
|
|
726
|
+
return dragging;
|
|
727
|
+
},
|
|
728
|
+
spherical,
|
|
729
|
+
target,
|
|
730
|
+
apply,
|
|
731
|
+
frame(box, distanceMul = 1) {
|
|
732
|
+
const centre = box.getCenter(new THREE.Vector3());
|
|
733
|
+
const size = box.getSize(new THREE.Vector3());
|
|
734
|
+
target.copy(centre);
|
|
735
|
+
// Aim slightly above the centroid, which seats the model a little below
|
|
736
|
+
// the middle of frame: a tall prop (the 3.6 m lantern) reads better with
|
|
737
|
+
// its mass low than dead-centred.
|
|
738
|
+
target.y = box.min.y + size.y * 0.55;
|
|
739
|
+
|
|
740
|
+
// Fit the bounding sphere against whichever field of view is narrower -
|
|
741
|
+
// on a phone in portrait that is the horizontal one, and framing off the
|
|
742
|
+
// vertical alone is what crops an asset off the sides.
|
|
743
|
+
const sphere = Math.max(size.length() * 0.5 + Math.abs(target.y - centre.y), 0.05);
|
|
744
|
+
const vFov = THREE.MathUtils.degToRad(camera.fov);
|
|
745
|
+
const hFov = 2 * Math.atan(Math.tan(vFov / 2) * Math.max(camera.aspect, 0.2));
|
|
746
|
+
home.radius = (sphere / Math.sin(Math.min(vFov, hFov) / 2)) * 1.22 * distanceMul;
|
|
747
|
+
|
|
748
|
+
spherical.radius = home.radius;
|
|
749
|
+
spherical.phi = home.phi;
|
|
750
|
+
spherical.theta = home.theta;
|
|
751
|
+
apply();
|
|
752
|
+
},
|
|
753
|
+
reset() {
|
|
754
|
+
spherical.radius = home.radius;
|
|
755
|
+
spherical.phi = home.phi;
|
|
756
|
+
spherical.theta = home.theta;
|
|
757
|
+
apply();
|
|
758
|
+
},
|
|
759
|
+
};
|
|
760
|
+
}
|