@expofp/renderer 3.44.8 → 3.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Renderer.js +7 -1
- package/dist/controls/hooks.d.ts +2 -1
- package/dist/core/sprites/BatchedSprites.js +2 -34
- package/dist/core/text/MemberTexture.js +49 -8
- package/dist/events/LayoutDriver.d.ts +5 -0
- package/dist/events/LayoutDriver.js +136 -29
- package/dist/events/layout-targets.d.ts +45 -3
- package/dist/events/layout-targets.js +129 -9
- package/dist/events/placement/box.d.ts +59 -0
- package/dist/events/placement/box.js +96 -0
- package/dist/events/placement/grid.d.ts +161 -0
- package/dist/events/placement/grid.js +235 -0
- package/dist/events/placement/index.d.ts +2 -0
- package/dist/events/placement/index.js +1 -0
- package/dist/events/placement/pass.d.ts +70 -0
- package/dist/events/placement/pass.js +617 -0
- package/dist/objects/images/ImageInstance.js +1 -0
- package/dist/objects/images/Images.js +2 -2
- package/dist/objects/images/image-container.d.ts +12 -0
- package/dist/objects/images/image-container.js +20 -4
- package/dist/objects/materials/TextureMaterial.d.ts +11 -8
- package/dist/objects/materials/TextureMaterial.js +24 -17
- package/dist/objects/text/text-container.d.ts +16 -1
- package/dist/objects/text/text-container.js +23 -1
- package/dist/space/layout-measure.d.ts +12 -10
- package/dist/space/layout-measure.js +18 -16
- package/dist/types/defs.d.ts +157 -147
- package/dist/types/events.d.ts +5 -1
- package/dist/types/guards.d.ts +7 -0
- package/dist/types/guards.js +10 -0
- package/dist/utils/math.d.ts +18 -1
- package/dist/utils/math.js +35 -0
- package/package.json +2 -2
package/dist/Renderer.js
CHANGED
|
@@ -63,7 +63,13 @@ export function Renderer(props) {
|
|
|
63
63
|
// and its own bootstrap effect must run once per context, not be double-invoked into churn.
|
|
64
64
|
return (_jsx(ExternalCanvas, { gl: gl, requestFrame: requestFrame, onFrame: onFrame, children: tree }));
|
|
65
65
|
}
|
|
66
|
-
return (_jsx(StrictMode, { children: _jsx(Suspense, { fallback: null, children: _jsx(CanvasWithDebug, { ...canvasProps,
|
|
66
|
+
return (_jsx(StrictMode, { children: _jsx(Suspense, { fallback: null, children: _jsx(CanvasWithDebug, { ...canvasProps,
|
|
67
|
+
// No `dpr`: R3F's default caps the buffer at 2x, and that is a performance choice rather
|
|
68
|
+
// than a size one. Every pixel-authored size is derived from CSS units and the pixel size
|
|
69
|
+
// the renderer publishes (also CSS), so a 3x canvas rasterized by a caller still draws at
|
|
70
|
+
// the size it asked for while the buffer stays at 2x. A 3x buffer is 2.25 times the area
|
|
71
|
+
// of a 2x one, on exactly the devices that can least afford it.
|
|
72
|
+
debugEnabled: debug,
|
|
67
73
|
// R3F force-writes ACES filmic tone mapping onto a fresh renderer unless `flat` is set,
|
|
68
74
|
// which lands it on three's own default, `NoToneMapping`, instead. This renderer does not
|
|
69
75
|
// tone map: `Lighting` keeps the rig's light sum in range by construction, rather than
|
package/dist/controls/hooks.d.ts
CHANGED
|
@@ -85,8 +85,9 @@ export interface CameraState {
|
|
|
85
85
|
/** How far the plan is tilted away from face-on, in degrees. */
|
|
86
86
|
pitch: number;
|
|
87
87
|
/**
|
|
88
|
-
* How much of the plan one
|
|
88
|
+
* How much of the plan one CSS pixel spans, in plan units — the factor anything sized in
|
|
89
89
|
* pixels is laid out against, and the quantity the imperative renderer published as `ptScale`.
|
|
90
|
+
* The device ratio is not part of it.
|
|
90
91
|
*/
|
|
91
92
|
pixelSize: number;
|
|
92
93
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { extend } from '@react-three/fiber';
|
|
2
|
-
import { Matrix4, PlaneGeometry, Vector2,
|
|
2
|
+
import { Matrix4, PlaneGeometry, Vector2, } from 'three';
|
|
3
|
+
import { aimDepth } from '../../utils/math';
|
|
3
4
|
import { InstancedMeshImpl } from '../instanced/InstancedMesh';
|
|
4
5
|
import { RaycastingSprites } from './RaycastingSprites';
|
|
5
6
|
import { ANCHOR_SCHEMA, ANCHOR_UNIFORM, patchSpriteMaterial, spriteReferenceDepth, } from './sprite-material';
|
|
@@ -165,41 +166,8 @@ export const BatchedSprites = extend(BatchedSpritesImpl);
|
|
|
165
166
|
function furthestCorner(anchor) {
|
|
166
167
|
return Math.hypot(0.5 + Math.abs(anchor.x), 0.5 + Math.abs(anchor.y));
|
|
167
168
|
}
|
|
168
|
-
/**
|
|
169
|
-
* How far the camera's own axis travels before it meets a batch's own plane, in world units.
|
|
170
|
-
*
|
|
171
|
-
* That is the depth a plan-unit size is exact at, so it is what the shader cancels the perspective
|
|
172
|
-
* divide against. It has to agree with the depth `measurePixelSize` in `space/layout-measure.ts`
|
|
173
|
-
* works at, because a caller's `onScale` rule sizes a def in plan units from the pixel size that
|
|
174
|
-
* function reports. Without controls mounted that function reads this same crossing point. With
|
|
175
|
-
* them it reads their distance to their target, and the two agree while the target sits on the
|
|
176
|
-
* plan, which is what orbiting a floor plan keeps true.
|
|
177
|
-
*
|
|
178
|
-
* The plane is the batch's own, taken from its world matrix, because a sprite batch draws at plan
|
|
179
|
-
* `z = 0` and whatever places the plan places the mesh with it.
|
|
180
|
-
* @param camera - The camera this draw uses.
|
|
181
|
-
* @param planToWorld - The batch's world matrix.
|
|
182
|
-
* @returns The distance in world units, or `0` for a camera that never meets the plane ahead of it.
|
|
183
|
-
*/
|
|
184
|
-
function aimDepth(camera, planToWorld) {
|
|
185
|
-
// A camera looks down its own negative z.
|
|
186
|
-
const direction = _direction.setFromMatrixColumn(camera.matrixWorld, 2).normalize().negate();
|
|
187
|
-
const normal = _normal.setFromMatrixColumn(planToWorld, 2).normalize();
|
|
188
|
-
const facing = direction.dot(normal);
|
|
189
|
-
// The camera looks along the plane and never meets it.
|
|
190
|
-
if (facing === 0)
|
|
191
|
-
return 0;
|
|
192
|
-
const eye = _eye.setFromMatrixPosition(camera.matrixWorld);
|
|
193
|
-
const depth = _origin.setFromMatrixPosition(planToWorld).sub(eye).dot(normal) / facing;
|
|
194
|
-
// Behind the camera is no measurement, so the shader keeps its plan-unit sizing instead.
|
|
195
|
-
return depth > 0 ? depth : 0;
|
|
196
|
-
}
|
|
197
169
|
/** The quad every sprite batch draws. One per process. See `BatchedSpritesImpl.geometry`. */
|
|
198
170
|
const SPRITE_QUAD = new PlaneGeometry(1, 1);
|
|
199
171
|
// Scratch, each filled and consumed within one synchronous call.
|
|
200
172
|
const _anchor = new Vector2();
|
|
201
173
|
const _matrix = new Matrix4();
|
|
202
|
-
const _direction = new Vector3();
|
|
203
|
-
const _normal = new Vector3();
|
|
204
|
-
const _eye = new Vector3();
|
|
205
|
-
const _origin = new Vector3();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Color, DataTexture, FloatType, Frustum, Matrix4, RGBAFormat, Sphere, Vector3, } from 'three';
|
|
2
2
|
import { Y_DOWN_FLIP } from '../../space/Space';
|
|
3
|
+
import { aimDepth } from '../../utils/math';
|
|
3
4
|
/**
|
|
4
5
|
* The data texture a batch's shader reads each member's transform and style from: one 32-float row
|
|
5
6
|
* per member, refilled once per draw with frustum culling folded in.
|
|
@@ -58,12 +59,20 @@ export class MemberTexture {
|
|
|
58
59
|
// member, so reading it per member made this loop quadratic — and it returns an *array* once
|
|
59
60
|
// outlines are on, whose `.color` is undefined. The material being drawn is already in hand.
|
|
60
61
|
const batchColor = host.color ?? material.color ?? 0xffffff;
|
|
62
|
+
// What a screen-space member's size is exact at, and where the camera looks from. Zero turns the
|
|
63
|
+
// correction off, for an orthographic camera (no divide to cancel) and for one that never meets
|
|
64
|
+
// the plan. See `depthScaleOf`.
|
|
65
|
+
const reference = camera.isPerspectiveCamera
|
|
66
|
+
? aimDepth(camera, host.matrixWorld)
|
|
67
|
+
: 0;
|
|
68
|
+
_eye.setFromMatrixPosition(camera.matrixWorld);
|
|
69
|
+
_forward.setFromMatrixColumn(camera.matrixWorld, 2).normalize().negate();
|
|
61
70
|
for (let memberId = 0; memberId < rowCount; memberId++) {
|
|
62
71
|
const text = host.getText(memberId);
|
|
63
72
|
// An unsynced member has no layout to write and draws nothing either.
|
|
64
73
|
if (!text?.textRenderInfo)
|
|
65
74
|
continue;
|
|
66
|
-
faceCamera(text, _basis);
|
|
75
|
+
faceCamera(text, _basis, reference > 0 ? depthScaleOf(text, host.matrixWorld, reference) : 1);
|
|
67
76
|
const start = memberId * FLOATS_PER_MEMBER;
|
|
68
77
|
if (isDrawn(text)) {
|
|
69
78
|
this.writeMember(texData, start, text, material, batchColor);
|
|
@@ -172,6 +181,32 @@ export class MemberTexture {
|
|
|
172
181
|
}
|
|
173
182
|
}
|
|
174
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* How much a screen-space member is scaled so that it keeps the on-screen size its plan-unit size
|
|
186
|
+
* asks for wherever it sits in a perspective frame.
|
|
187
|
+
*
|
|
188
|
+
* The same correction a sprite's shader applies to an image (`spriteDepthScale` in
|
|
189
|
+
* `sprites/sprite-material.ts`), and for the same reason: a member sized in plan units shrinks by
|
|
190
|
+
* the projection's divide, and a pitched camera spreads the plan across a range of depths, so one
|
|
191
|
+
* pixel size is right only where the camera's axis crosses the plan. Without it an image beside its
|
|
192
|
+
* text is constant in pixels while the text is not, and anything laid out inside the image's
|
|
193
|
+
* pixels (a count in a counter circle) slides off it as the pin moves up or down the screen.
|
|
194
|
+
* @param text - The member, with its sprite anchor set.
|
|
195
|
+
* @param matrixWorld - The batch's world matrix.
|
|
196
|
+
* @param reference - The depth the plan-unit size is exact at. Must be positive.
|
|
197
|
+
* @returns The scale, one at the reference depth. One as well for an anchor at or behind the eye.
|
|
198
|
+
*/
|
|
199
|
+
function depthScaleOf(text, matrixWorld, reference) {
|
|
200
|
+
const anchor = text.spriteAnchor;
|
|
201
|
+
if (!anchor)
|
|
202
|
+
return 1;
|
|
203
|
+
const depth = _offset
|
|
204
|
+
.set(anchor.x, anchor.y, text.position.z)
|
|
205
|
+
.applyMatrix4(matrixWorld)
|
|
206
|
+
.sub(_eye)
|
|
207
|
+
.dot(_forward);
|
|
208
|
+
return depth > 0 ? depth / reference : 1;
|
|
209
|
+
}
|
|
175
210
|
/**
|
|
176
211
|
* Turns a screen-space member to face the camera, and leaves a flat one alone.
|
|
177
212
|
*
|
|
@@ -180,20 +215,23 @@ export class MemberTexture {
|
|
|
180
215
|
* `BatchedTextImpl.raycast` and `isDrawn`. This path costs neither, because both of those read the
|
|
181
216
|
* member's matrix already. Write the sprite matrix there and both follow.
|
|
182
217
|
*
|
|
183
|
-
* The composition is
|
|
184
|
-
* basis
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
218
|
+
* The composition is
|
|
219
|
+
* `T(anchor) . basis . S(depth) . T(position - anchor) . Rz(rotation.z) . S(scale)`. The basis is
|
|
220
|
+
* the camera's rotation brought into the batch's own space, corrected by `Y_DOWN_FLIP` for the
|
|
221
|
+
* camera's own rest pose (see {@link Y_DOWN_FLIP} in `space/Space.ts`). `S(depth)` is
|
|
222
|
+
* {@link depthScaleOf}, which scales the offset from the anchor along with the glyphs. Everything
|
|
223
|
+
* else is read off the member exactly as `updateMatrix` reads it. Straight overhead with the
|
|
224
|
+
* anchor at the reference depth the basis is the identity and the depth scale is one, so this
|
|
225
|
+
* reduces to the flat matrix, which is the equality the top-down baseline asserts on screen.
|
|
189
226
|
*
|
|
190
227
|
* Written through `setMatrix` only when it changed, so a still camera costs sixteen comparisons and
|
|
191
228
|
* no bounds invalidation. A pan or a zoom costs the same, because neither moves the camera's
|
|
192
229
|
* rotation. Only a roll or a pitch dirties a row.
|
|
193
230
|
* @param text - The member to turn.
|
|
194
231
|
* @param basis - The camera basis for this draw, in the batch's own space.
|
|
232
|
+
* @param depthScale - The member's perspective correction. One for none.
|
|
195
233
|
*/
|
|
196
|
-
function faceCamera(text, basis) {
|
|
234
|
+
function faceCamera(text, basis, depthScale) {
|
|
197
235
|
const anchor = text.spriteAnchor;
|
|
198
236
|
if (!anchor)
|
|
199
237
|
return;
|
|
@@ -202,6 +240,7 @@ function faceCamera(text, basis) {
|
|
|
202
240
|
_offset.set(text.position.x - anchor.x, text.position.y - anchor.y, 0);
|
|
203
241
|
_spriteMatrix
|
|
204
242
|
.copy(basis)
|
|
243
|
+
.multiply(_step.makeScale(depthScale, depthScale, depthScale))
|
|
205
244
|
.multiply(_step.makeTranslation(_offset.x, _offset.y, _offset.z))
|
|
206
245
|
.multiply(_step.makeRotationZ(text.rotation.z))
|
|
207
246
|
.scale(text.scale)
|
|
@@ -246,3 +285,5 @@ const _cameraBasis = new Matrix4();
|
|
|
246
285
|
const _spriteMatrix = new Matrix4();
|
|
247
286
|
const _step = new Matrix4();
|
|
248
287
|
const _offset = new Vector3();
|
|
288
|
+
const _eye = new Vector3();
|
|
289
|
+
const _forward = new Vector3();
|
|
@@ -21,6 +21,11 @@ export interface LayoutDriverProps {
|
|
|
21
21
|
* the user sees change lands first. The writes stay inside the frame callback, before three's own
|
|
22
22
|
* render, so they upload in the very frame that draws them.
|
|
23
23
|
*
|
|
24
|
+
* It also drives the registry's placement pass, which depends on the whole camera: a pan, a pitch
|
|
25
|
+
* or a resize moves every screen box while the pixel size and roll sit still. The pass runs before
|
|
26
|
+
* the drain and before three's render, so a verdict is written in the frame that draws it.
|
|
27
|
+
* {@link runPlacement} decides when it runs.
|
|
28
|
+
*
|
|
24
29
|
* No React is involved in a re-layout. The handlers mutate def fields and the defs' own sync closures
|
|
25
30
|
* write them, so a zoom does not re-render a single component — which is what makes it affordable for
|
|
26
31
|
* every label in a plan to re-lay out while the camera moves.
|
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Point } from '@expofp/geometry';
|
|
2
3
|
import { useFrame, useThree } from '@react-three/fiber';
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
import { Frustum } from 'three';
|
|
5
|
-
import { useCameraControls } from '../controls';
|
|
4
|
+
import { useRef, useState } from 'react';
|
|
5
|
+
import { Frustum, Matrix4, Vector2, Vector4 } from 'three';
|
|
6
|
+
import { useCameraControls } from '../controls/hooks';
|
|
6
7
|
import { measurePixelSize, measureRoll, pixelSizeMoved } from '../space/layout-measure';
|
|
7
8
|
import { useSpace } from '../space/Space';
|
|
8
9
|
import { planFrustum } from './def-culling';
|
|
9
10
|
import { createLayoutTargets, LayoutTargetsContext } from './layout-targets';
|
|
10
|
-
/**
|
|
11
|
-
* How much of a frame one drain may spend writing laid-out defs into their slots, in milliseconds —
|
|
12
|
-
* the same budget the legacy renderer's `UpdatesSystem` drained its update queue with. Small enough
|
|
13
|
-
* to leave a 60fps frame its render; the backlog it leaves is carried into the next frame.
|
|
14
|
-
*/
|
|
15
|
-
const LAYOUT_BUDGET_MS = 3;
|
|
16
11
|
/**
|
|
17
12
|
* Drives camera-driven re-layout: it owns the renderer's {@link LayoutTargets} and watches both
|
|
18
13
|
* layout axes every frame — the pixel size, swept past an epsilon, and the roll, swept on any
|
|
@@ -30,6 +25,11 @@ const LAYOUT_BUDGET_MS = 3;
|
|
|
30
25
|
* the user sees change lands first. The writes stay inside the frame callback, before three's own
|
|
31
26
|
* render, so they upload in the very frame that draws them.
|
|
32
27
|
*
|
|
28
|
+
* It also drives the registry's placement pass, which depends on the whole camera: a pan, a pitch
|
|
29
|
+
* or a resize moves every screen box while the pixel size and roll sit still. The pass runs before
|
|
30
|
+
* the drain and before three's render, so a verdict is written in the frame that draws it.
|
|
31
|
+
* {@link runPlacement} decides when it runs.
|
|
32
|
+
*
|
|
33
33
|
* No React is involved in a re-layout. The handlers mutate def fields and the defs' own sync closures
|
|
34
34
|
* write them, so a zoom does not re-render a single component — which is what makes it affordable for
|
|
35
35
|
* every label in a plan to re-lay out while the camera moves.
|
|
@@ -48,38 +48,145 @@ export function LayoutDriver(props) {
|
|
|
48
48
|
// the controls — absent at this point whatever the mount order, since they publish themselves in
|
|
49
49
|
// an effect and this runs during render — which is also what lets this component be rendered,
|
|
50
50
|
// and tested, without any controls at all.
|
|
51
|
-
const [targets] = useState(() => createLayoutTargets(invalidate, measurePixelSize(camera, space, gl.domElement.width) ?? 1, measureRoll(camera, space, gl.domElement.width, gl.domElement.height) ?? 0));
|
|
51
|
+
const [targets] = useState(() => createLayoutTargets(invalidate, measurePixelSize(camera, space, gl.domElement.width / gl.getPixelRatio()) ?? 1, measureRoll(camera, space, gl.domElement.width / gl.getPixelRatio(), gl.domElement.height / gl.getPixelRatio()) ?? 0));
|
|
52
|
+
// Frame state for the placement debounce. Nothing renders on it.
|
|
53
|
+
const placementRef = useRef({
|
|
54
|
+
seen: new Matrix4(),
|
|
55
|
+
size: new Vector2(),
|
|
56
|
+
frameAt: -Infinity,
|
|
57
|
+
changedAt: -Infinity,
|
|
58
|
+
placedAt: -Infinity,
|
|
59
|
+
});
|
|
52
60
|
useFrame((state) => {
|
|
53
|
-
const camera = state
|
|
54
|
-
const { width, height } =
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
//
|
|
59
|
-
const pixelSize = measurePixelSize(camera, space,
|
|
60
|
-
// The epsilon compares against the sweep's target size, so a camera that stopped mid-sweep does
|
|
61
|
-
// not re-mark the same sweep on every one of the frames that finish it.
|
|
61
|
+
const { camera, gl, viewport } = state;
|
|
62
|
+
const { width, height } = gl.domElement;
|
|
63
|
+
// Everything the renderer publishes is in CSS pixels, so the device ratio stops here.
|
|
64
|
+
const cssWidth = width / viewport.dpr;
|
|
65
|
+
const cssHeight = height / viewport.dpr;
|
|
66
|
+
// 1. The pixel size, swept past an epsilon against the sweep's own target.
|
|
67
|
+
const pixelSize = measurePixelSize(camera, space, cssWidth, controls);
|
|
62
68
|
if (pixelSize !== undefined && pixelSizeMoved(pixelSize, targets.pixelSize)) {
|
|
63
69
|
targets.applyScale(pixelSize);
|
|
64
70
|
}
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
// no-write. Which of the two ways it is reached — the controls' own angle, or a measurement off
|
|
68
|
-
// the camera for external mode and bare scenes — is `measureRoll`'s to choose.
|
|
69
|
-
const roll = measureRoll(camera, space, width, height, controls);
|
|
71
|
+
// 2. The roll, swept on any change: the rules step in quarter turns, so most are no-writes.
|
|
72
|
+
const roll = measureRoll(camera, space, cssWidth, cssHeight, controls);
|
|
70
73
|
if (roll !== undefined && roll !== targets.roll)
|
|
71
74
|
targets.applyRoll(roll);
|
|
75
|
+
// 3. The placement pass, ahead of the drain so its verdicts are written this frame.
|
|
76
|
+
const view = {
|
|
77
|
+
space,
|
|
78
|
+
camera,
|
|
79
|
+
pixelSize: targets.pixelSize,
|
|
80
|
+
width: cssWidth,
|
|
81
|
+
height: cssHeight,
|
|
82
|
+
};
|
|
83
|
+
if (runPlacement(targets, placementRef.current, view, performance.now()))
|
|
84
|
+
invalidate();
|
|
85
|
+
// 4. The writes, inside the frustum first, on a budget. A leftover backlog asks for a frame.
|
|
72
86
|
if (!targets.hasWork)
|
|
73
87
|
return;
|
|
74
|
-
|
|
75
|
-
// so a camera that pans mid-sweep re-prioritizes what is left.
|
|
76
|
-
const frustum = planFrustum(state.camera, space.matrix, _frustum);
|
|
77
|
-
// The writes self-invalidate through the defs' slot writers; this asks for the *extra* frame a
|
|
78
|
-
// leftover backlog needs, which is what keeps the demand loop ticking until the sweep is done.
|
|
88
|
+
const frustum = planFrustum(camera, space.matrix, _frustum);
|
|
79
89
|
if (targets.drain(frustum, LAYOUT_BUDGET_MS))
|
|
80
90
|
invalidate();
|
|
81
91
|
});
|
|
82
92
|
return _jsx(LayoutTargetsContext, { value: targets, children: children });
|
|
83
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Runs the placement pass once the plan has not visibly moved on screen for
|
|
96
|
+
* {@link PLACEMENT_DEBOUNCE_MS}, and at once when a participant registers, leaves or moves. Every
|
|
97
|
+
* pass reports.
|
|
98
|
+
*
|
|
99
|
+
* Visibly means faster than {@link SETTLE_SPEED_PX_PER_S}. The controls' easing tail, which
|
|
100
|
+
* creeps for hundreds of milliseconds after the plan looks still, therefore does not hold the pass.
|
|
101
|
+
* @param targets - The registry.
|
|
102
|
+
* @param state - The debounce state, updated here.
|
|
103
|
+
* @param context - The pass's context.
|
|
104
|
+
* @param now - The clock, in milliseconds.
|
|
105
|
+
* @returns Whether the driver must ask for a frame: a pass is waiting out the debounce.
|
|
106
|
+
*/
|
|
107
|
+
function runPlacement(targets, state, context, now) {
|
|
108
|
+
const { space, camera, width, height } = context;
|
|
109
|
+
const step = Math.min(now - state.frameAt, MAX_STEP_MS);
|
|
110
|
+
const motion = screenMotion(space, camera, state.seen, width, height);
|
|
111
|
+
const resized = state.size.x !== width || state.size.y !== height;
|
|
112
|
+
if (resized || motion > (SETTLE_SPEED_PX_PER_S * step) / 1000)
|
|
113
|
+
state.changedAt = now;
|
|
114
|
+
state.seen.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
|
|
115
|
+
state.size.set(width, height);
|
|
116
|
+
state.frameAt = now;
|
|
117
|
+
const pending = state.changedAt > state.placedAt;
|
|
118
|
+
const owed = targets.needsPlacement || targets.placementDrifted;
|
|
119
|
+
if (!owed && !(pending && now - state.changedAt >= PLACEMENT_DEBOUNCE_MS))
|
|
120
|
+
return pending;
|
|
121
|
+
state.placedAt = now;
|
|
122
|
+
targets.applyPlacement(context);
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* How far the plan moved on screen between the last frame's camera and this one, in CSS pixels:
|
|
127
|
+
* the most that any of a grid of points across the canvas moved. Each point is cast onto the plan
|
|
128
|
+
* through this frame's camera and projected back through the last frame's, so a pan, a zoom, a
|
|
129
|
+
* turn and a tilt all count, by however far they move what is on screen. A point that misses the
|
|
130
|
+
* plan, above the horizon, is skipped, as is one the last frame had behind the camera.
|
|
131
|
+
* @param space - The scene's space.
|
|
132
|
+
* @param camera - This frame's camera, its matrices up to date.
|
|
133
|
+
* @param previous - `projection * viewInverse` as of the last frame.
|
|
134
|
+
* @param cssWidth - The canvas width in CSS pixels.
|
|
135
|
+
* @param cssHeight - The canvas height in CSS pixels.
|
|
136
|
+
* @returns The motion in CSS pixels: 0 for a camera that did not move, and `Infinity` when no
|
|
137
|
+
* point lands on the plan, so a camera that sees none of it settles only by standing still.
|
|
138
|
+
*/
|
|
139
|
+
function screenMotion(space, camera, previous, cssWidth, cssHeight) {
|
|
140
|
+
_motionNow.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
|
|
141
|
+
if (_motionNow.equals(previous))
|
|
142
|
+
return 0;
|
|
143
|
+
let motion = -1;
|
|
144
|
+
for (const x of MOTION_SAMPLES) {
|
|
145
|
+
for (const y of MOTION_SAMPLES) {
|
|
146
|
+
const plan = space.ndcToPlan(_motionNdc.set(x, y), camera, _motionPlan);
|
|
147
|
+
if (!plan)
|
|
148
|
+
continue;
|
|
149
|
+
_motionClip.set(plan.x, plan.y, 0, 1).applyMatrix4(space.matrix).applyMatrix4(previous);
|
|
150
|
+
if (_motionClip.w <= 0)
|
|
151
|
+
continue;
|
|
152
|
+
const dx = (_motionClip.x / _motionClip.w - x) * (cssWidth / 2);
|
|
153
|
+
const dy = (_motionClip.y / _motionClip.w - y) * (cssHeight / 2);
|
|
154
|
+
motion = Math.max(motion, Math.hypot(dx, dy));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return motion < 0 ? Infinity : motion;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* How much of a frame one drain may spend writing laid-out defs into their slots, in milliseconds —
|
|
161
|
+
* the same budget the legacy renderer's `UpdatesSystem` drained its update queue with. Small enough
|
|
162
|
+
* to leave a 60fps frame its render; the backlog it leaves is carried into the next frame.
|
|
163
|
+
*/
|
|
164
|
+
const LAYOUT_BUDGET_MS = 3;
|
|
165
|
+
/**
|
|
166
|
+
* How long the view must stay unchanged before the placement pass runs, in milliseconds. Long
|
|
167
|
+
* enough that a drag or a run of wheel events reports once, not on every event.
|
|
168
|
+
*/
|
|
169
|
+
const PLACEMENT_DEBOUNCE_MS = 60;
|
|
170
|
+
/**
|
|
171
|
+
* How fast the plan may move on screen, in CSS pixels a second, and still count as still: one CSS
|
|
172
|
+
* pixel a frame at 60 Hz. Each frame is held against its own time step, so the rule is the same at
|
|
173
|
+
* any frame rate.
|
|
174
|
+
*/
|
|
175
|
+
const SETTLE_SPEED_PX_PER_S = 60;
|
|
176
|
+
/**
|
|
177
|
+
* The longest time step a frame's motion is measured over, in milliseconds. The first frame after an
|
|
178
|
+
* idle loop would otherwise read any jump as slow.
|
|
179
|
+
*/
|
|
180
|
+
const MAX_STEP_MS = 1000 / 30;
|
|
181
|
+
/**
|
|
182
|
+
* Where {@link screenMotion} samples the canvas, in normalized device coordinates along each axis:
|
|
183
|
+
* a 3 by 3 grid reaching nearly to the edges, where a zoom or a turn moves the plan most.
|
|
184
|
+
*/
|
|
185
|
+
const MOTION_SAMPLES = [-0.96, 0, 0.96];
|
|
84
186
|
// Scratch for the drain's frustum, filled and consumed within one frame callback.
|
|
85
187
|
const _frustum = new Frustum();
|
|
188
|
+
// Scratch for screenMotion, filled and consumed within one call.
|
|
189
|
+
const _motionNow = new Matrix4();
|
|
190
|
+
const _motionNdc = new Vector2();
|
|
191
|
+
const _motionPlan = new Point();
|
|
192
|
+
const _motionClip = new Vector4();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Frustum } from 'three';
|
|
2
2
|
import { type LayerDef, type RenderableDef } from '../types/defs';
|
|
3
|
+
import { type PlacementContext } from './placement';
|
|
3
4
|
/**
|
|
4
5
|
* The defs a renderer re-lays out when the camera moves: the pixel size and the roll are the two
|
|
5
6
|
* layout axes.
|
|
@@ -30,14 +31,32 @@ export interface LayoutTargets {
|
|
|
30
31
|
readonly roll: number;
|
|
31
32
|
/** Whether writes are still queued — what makes a caller keep frames coming. */
|
|
32
33
|
readonly hasWork: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Whether a pass is owed because a participant registered, unregistered or was passed to
|
|
36
|
+
* `update()` since the last one ran.
|
|
37
|
+
*
|
|
38
|
+
* The driver also runs a pass when the camera moves, which this does not report: a camera move is
|
|
39
|
+
* the driver's own observation, not the registry's.
|
|
40
|
+
*/
|
|
41
|
+
readonly needsPlacement: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Whether a participant's box moved since the last pass, through `update()`.
|
|
44
|
+
*
|
|
45
|
+
* The driver runs the pass at once for this, as for {@link needsPlacement}: the verdicts the def
|
|
46
|
+
* earned were earned at its old position.
|
|
47
|
+
*/
|
|
48
|
+
readonly placementDrifted: boolean;
|
|
33
49
|
/**
|
|
34
50
|
* Registers defs for re-layout, and lays out and writes the ones that are not current — so a def
|
|
35
51
|
* is current from the moment it is registered, and a membership change re-lays out only what it
|
|
36
52
|
* added.
|
|
37
|
-
* @param
|
|
53
|
+
* @param layer - The layer registering them, which is what places its participants in a collision
|
|
54
|
+
* scope: its own, or the one its {@link LayerDef.collisionGroup} names.
|
|
55
|
+
* @param defs - The defs to lay out; every one of them declares an `onScale`, an `onRoll` or a
|
|
56
|
+
* `placement`.
|
|
38
57
|
* @returns An unregister function; it also drops whatever of these defs a sweep still queued.
|
|
39
58
|
*/
|
|
40
|
-
register(defs: readonly RenderableDef[]): () => void;
|
|
59
|
+
register(layer: LayerDef, defs: readonly RenderableDef[]): () => void;
|
|
41
60
|
/**
|
|
42
61
|
* Records the new pixel size and runs every registered scale rule against it, queueing what they
|
|
43
62
|
* report changed. Nothing is written here — that is {@link drain}'s job. Applying again before
|
|
@@ -52,6 +71,28 @@ export interface LayoutTargets {
|
|
|
52
71
|
* @param roll - The new roll, in radians clockwise from north.
|
|
53
72
|
*/
|
|
54
73
|
applyRoll(roll: number): void;
|
|
74
|
+
/**
|
|
75
|
+
* Runs the placement pass over every registered participant and queues what the reports change:
|
|
76
|
+
* what each `onPlace` returns, then what each clustering layer's `onCluster` returns, through the
|
|
77
|
+
* same queue — a layer among them is written at once, as from any rule.
|
|
78
|
+
*
|
|
79
|
+
* The third `apply*`, and the one that is not an axis: placement depends on the whole camera
|
|
80
|
+
* rather than on one quantity, so the driver decides when to call this rather than comparing a
|
|
81
|
+
* number. The pass itself is synchronous and complete; only the writes it produces are budgeted,
|
|
82
|
+
* through the same queue {@link drain} works off.
|
|
83
|
+
*
|
|
84
|
+
* A registered def that no longer participates, because its `placement` was removed or it stopped
|
|
85
|
+
* being screen-space, is forgotten by the pass: it has no handler left to tell, and forgetting
|
|
86
|
+
* means that participating again reports its verdict afresh. A caller that removes `placement`
|
|
87
|
+
* owns restoring the def's appearance: one left shrunk or faded by its last `onPlace(false)`
|
|
88
|
+
* stays that way, because the handler left with the options and nothing can report to it.
|
|
89
|
+
*
|
|
90
|
+
* A known gap follows from the same removal: the defs it was holding down stay stood down, until
|
|
91
|
+
* something else owes a pass, because removing an option is a plain mutation that notifies
|
|
92
|
+
* nobody. Their room is free, and nothing re-offers it.
|
|
93
|
+
* @param context - What the camera shows this pass.
|
|
94
|
+
*/
|
|
95
|
+
applyPlacement(context: PlacementContext): void;
|
|
55
96
|
/**
|
|
56
97
|
* Writes queued defs within a frame's budget, and says whether any remain — the caller's cue to
|
|
57
98
|
* ask for another frame.
|
|
@@ -79,7 +120,8 @@ export interface LayoutTargets {
|
|
|
79
120
|
export declare const LayoutTargetsContext: import("react").Context<LayoutTargets | undefined>;
|
|
80
121
|
/**
|
|
81
122
|
* Registers a layer's re-layout targets for as long as the layer is mounted: the defs it holds that
|
|
82
|
-
* declare an `onScale`, and the layer itself when it declares
|
|
123
|
+
* declare an `onScale`, an `onRoll` or a `placement`, and the layer itself when it declares a
|
|
124
|
+
* layout rule.
|
|
83
125
|
*
|
|
84
126
|
* A child *layer* is never registered here even when it declares a handler — it registers itself, and
|
|
85
127
|
* it has to: a handler assigned to a layer after mount re-renders that layer alone, so a registration
|