@opsydyn/effect-three-foldkit 0.0.0-stage → 0.1.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/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +99 -2
- package/dist/assets.d.ts +53 -0
- package/dist/assets.d.ts.map +1 -0
- package/dist/dom/animationFrames.d.ts +12 -0
- package/dist/dom/animationFrames.d.ts.map +1 -0
- package/dist/dom/canvas.d.ts +26 -0
- package/dist/dom/canvas.d.ts.map +1 -0
- package/dist/dom/latch.d.ts +15 -0
- package/dist/dom/latch.d.ts.map +1 -0
- package/dist/dom/models.d.ts +10 -0
- package/dist/dom/models.d.ts.map +1 -0
- package/dist/dom/overlays.d.ts +24 -0
- package/dist/dom/overlays.d.ts.map +1 -0
- package/dist/dom/pointer.d.ts +49 -0
- package/dist/dom/pointer.d.ts.map +1 -0
- package/dist/dom/postprocessing.d.ts +22 -0
- package/dist/dom/postprocessing.d.ts.map +1 -0
- package/dist/dom/property.d.ts +15 -0
- package/dist/dom/property.d.ts.map +1 -0
- package/dist/dom/reconciled.d.ts +10 -0
- package/dist/dom/reconciled.d.ts.map +1 -0
- package/dist/dom/renderSettings.d.ts +58 -0
- package/dist/dom/renderSettings.d.ts.map +1 -0
- package/dist/dom/typedScenes.d.ts +14 -0
- package/dist/dom/typedScenes.d.ts.map +1 -0
- package/dist/dom/updates.d.ts +8 -0
- package/dist/dom/updates.d.ts.map +1 -0
- package/dist/dom/viewport.d.ts +40 -0
- package/dist/dom/viewport.d.ts.map +1 -0
- package/dist/dom/webgl.d.ts +12 -0
- package/dist/dom/webgl.d.ts.map +1 -0
- package/dist/dom/webgpu.d.ts +23 -0
- package/dist/dom/webgpu.d.ts.map +1 -0
- package/dist/dom/webgpuPostprocessing.d.ts +4 -0
- package/dist/dom/webgpuPostprocessing.d.ts.map +1 -0
- package/dist/dom/xr.d.ts +53 -0
- package/dist/dom/xr.d.ts.map +1 -0
- package/dist/dom/xrGlide.d.ts +49 -0
- package/dist/dom/xrGlide.d.ts.map +1 -0
- package/dist/dom/xrHub.d.ts +45 -0
- package/dist/dom/xrHub.d.ts.map +1 -0
- package/dist/dom/xrInputs.d.ts +40 -0
- package/dist/dom/xrInputs.d.ts.map +1 -0
- package/dist/dom/xrSpaces.d.ts +33 -0
- package/dist/dom/xrSpaces.d.ts.map +1 -0
- package/dist/dom/xrTarget.d.ts +36 -0
- package/dist/dom/xrTarget.d.ts.map +1 -0
- package/dist/element.d.ts +771 -0
- package/dist/element.d.ts.map +1 -0
- package/dist/environment.d.ts +64 -0
- package/dist/environment.d.ts.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2037 -0
- package/dist/index.js.map +38 -0
- package/dist/mount.d.ts +14 -0
- package/dist/mount.d.ts.map +1 -0
- package/dist/overlayView.d.ts +23 -0
- package/dist/overlayView.d.ts.map +1 -0
- package/dist/view.d.ts +179 -0
- package/dist/view.d.ts.map +1 -0
- package/dist/xr.d.ts +34 -0
- package/dist/xr.d.ts.map +1 -0
- package/package.json +72 -4
- package/src/assets.ts +151 -0
- package/src/dom/animationFrames.ts +32 -0
- package/src/dom/canvas.ts +54 -0
- package/src/dom/latch.ts +36 -0
- package/src/dom/models.ts +37 -0
- package/src/dom/overlays.ts +63 -0
- package/src/dom/pointer.ts +163 -0
- package/src/dom/postprocessing.ts +116 -0
- package/src/dom/property.ts +43 -0
- package/src/dom/reconciled.ts +51 -0
- package/src/dom/renderSettings.ts +117 -0
- package/src/dom/typedScenes.ts +21 -0
- package/src/dom/updates.ts +14 -0
- package/src/dom/viewport.ts +97 -0
- package/src/dom/webgl.ts +216 -0
- package/src/dom/webgpu.ts +204 -0
- package/src/dom/webgpuPostprocessing.ts +95 -0
- package/src/dom/xr.ts +211 -0
- package/src/dom/xrGlide.ts +114 -0
- package/src/dom/xrHub.ts +118 -0
- package/src/dom/xrInputs.ts +309 -0
- package/src/dom/xrSpaces.ts +287 -0
- package/src/dom/xrTarget.ts +71 -0
- package/src/element.ts +114 -0
- package/src/environment.ts +78 -0
- package/src/index.ts +71 -0
- package/src/mount.ts +305 -0
- package/src/overlayView.ts +71 -0
- package/src/view.ts +254 -0
- package/src/xr.ts +36 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Moves the application's overlay HTML to where the scene placed each overlay, as Drei's
|
|
3
|
+
* `Html` does every frame (`src/web/Html.tsx` at `bf6f4ad`): a translate to the point,
|
|
4
|
+
* the distance scale, centring, the depth's z-index, and hiding when not visible.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
import type { OverlayPlacement } from '@opsydyn/effect-three';
|
|
9
|
+
|
|
10
|
+
/** The data attribute naming the overlay an element's HTML belongs to. */
|
|
11
|
+
export const overlayData = {
|
|
12
|
+
key: 'effect-three-overlay',
|
|
13
|
+
attribute: 'data-effect-three-overlay',
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
const transformOf = (placement: OverlayPlacement): string =>
|
|
17
|
+
`translate3d(${placement.x}px,${placement.y}px,0) scale(${placement.scale})${
|
|
18
|
+
placement.center ? ' translate(-50%,-50%)' : ''
|
|
19
|
+
}`;
|
|
20
|
+
|
|
21
|
+
/** Writes one placement, touching only the style values that changed. */
|
|
22
|
+
const write = (element: HTMLElement, placement: OverlayPlacement): void => {
|
|
23
|
+
const { style } = element;
|
|
24
|
+
const transform = transformOf(placement);
|
|
25
|
+
if (style.transform !== transform) style.transform = transform;
|
|
26
|
+
const zIndex = String(placement.zIndex);
|
|
27
|
+
if (style.zIndex !== zIndex) style.zIndex = zIndex;
|
|
28
|
+
const visibility = placement.visible ? 'visible' : 'hidden';
|
|
29
|
+
if (style.visibility !== visibility) style.visibility = visibility;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type OverlayWriter = {
|
|
33
|
+
/** Takes the latest placements and writes them. */
|
|
34
|
+
readonly place: (placements: ReadonlyArray<OverlayPlacement>) => void;
|
|
35
|
+
/** Writes the latest placements again, for overlay elements the view has just made. */
|
|
36
|
+
readonly rewrite: () => void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Creates the writer for overlays rendered inside `host`. */
|
|
40
|
+
export const createOverlayWriter = (host: Element): OverlayWriter => {
|
|
41
|
+
let latest: ReadonlyArray<OverlayPlacement> = [];
|
|
42
|
+
const rewrite = (): void => {
|
|
43
|
+
for (const placement of latest) {
|
|
44
|
+
const element = host.querySelector(
|
|
45
|
+
`[${overlayData.attribute}="${CSS.escape(placement.key)}"]`,
|
|
46
|
+
);
|
|
47
|
+
if (element instanceof HTMLElement) write(element, placement);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
place: (placements) => {
|
|
52
|
+
latest = placements;
|
|
53
|
+
rewrite();
|
|
54
|
+
},
|
|
55
|
+
rewrite,
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Makes the scene element the overlays' containing block, unless it already is one. */
|
|
60
|
+
export const containOverlays = (host: Element) => (): void => {
|
|
61
|
+
if (!(host instanceof HTMLElement)) return;
|
|
62
|
+
if (getComputedStyle(host).position === 'static') host.style.position = 'relative';
|
|
63
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM pointer events to the kernel's normalised device coordinates.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import type { Gestures, Modifiers, PointerInput, SceneEvent } from '@opsydyn/effect-three';
|
|
7
|
+
|
|
8
|
+
/** The part of `DOMRect` the conversion needs. */
|
|
9
|
+
export type Rect = {
|
|
10
|
+
readonly left: number;
|
|
11
|
+
readonly top: number;
|
|
12
|
+
readonly width: number;
|
|
13
|
+
readonly height: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Client coordinates to NDC: x and y in [-1, 1], y up. A zero-sized rect maps to the centre. */
|
|
17
|
+
export const toNdc = (
|
|
18
|
+
rect: Rect,
|
|
19
|
+
clientX: number,
|
|
20
|
+
clientY: number,
|
|
21
|
+
): { readonly x: number; readonly y: number } => {
|
|
22
|
+
if (rect.width === 0 || rect.height === 0) return { x: 0, y: 0 };
|
|
23
|
+
return {
|
|
24
|
+
x: ((clientX - rect.left) / rect.width) * 2 - 1,
|
|
25
|
+
y: -(((clientY - rect.top) / rect.height) * 2 - 1),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const modifiersOf = (event: MouseEvent): Modifiers => ({
|
|
30
|
+
shift: event.shiftKey,
|
|
31
|
+
control: event.ctrlKey,
|
|
32
|
+
meta: event.metaKey,
|
|
33
|
+
alt: event.altKey,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** The pointer's id, or 0 for mouse events that are not pointer events. */
|
|
37
|
+
const pointerIdOf = (event: MouseEvent): number =>
|
|
38
|
+
event instanceof PointerEvent ? event.pointerId : 0;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Wheel deltas in CSS pixels: line and page modes scaled as three.js's `OrbitControls`
|
|
42
|
+
* scales them (`_customWheelEvent`: 16 and 100).
|
|
43
|
+
*/
|
|
44
|
+
const wheelScale = [1, 16, 100] as const;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Converts a DOM pointer, mouse or wheel event into kernel input, or `undefined` for
|
|
48
|
+
* other event types. Pointer cancel is treated as leaving the surface.
|
|
49
|
+
*/
|
|
50
|
+
export const toPointerInput = (event: MouseEvent, rect: Rect): PointerInput | undefined => {
|
|
51
|
+
if (event.type === 'pointerleave' || event.type === 'pointercancel') {
|
|
52
|
+
return { _tag: 'PointerLeave' };
|
|
53
|
+
}
|
|
54
|
+
const { x, y } = toNdc(rect, event.clientX, event.clientY);
|
|
55
|
+
const pointer = pointerIdOf(event);
|
|
56
|
+
switch (event.type) {
|
|
57
|
+
case 'pointermove':
|
|
58
|
+
return { _tag: 'PointerMove', x, y, pointer };
|
|
59
|
+
case 'pointerdown':
|
|
60
|
+
return {
|
|
61
|
+
_tag: 'PointerDown',
|
|
62
|
+
x,
|
|
63
|
+
y,
|
|
64
|
+
button: event.button,
|
|
65
|
+
pointer,
|
|
66
|
+
modifiers: modifiersOf(event),
|
|
67
|
+
};
|
|
68
|
+
case 'pointerup':
|
|
69
|
+
return { _tag: 'PointerUp', x, y, button: event.button, pointer };
|
|
70
|
+
case 'dblclick':
|
|
71
|
+
return { _tag: 'DoubleClick', x, y, button: event.button };
|
|
72
|
+
case 'contextmenu':
|
|
73
|
+
return { _tag: 'ContextMenu', x, y };
|
|
74
|
+
case 'wheel':
|
|
75
|
+
return event instanceof WheelEvent
|
|
76
|
+
? {
|
|
77
|
+
_tag: 'Wheel',
|
|
78
|
+
x,
|
|
79
|
+
y,
|
|
80
|
+
deltaX: event.deltaX * (wheelScale[event.deltaMode] ?? 1),
|
|
81
|
+
deltaY: event.deltaY * (wheelScale[event.deltaMode] ?? 1),
|
|
82
|
+
modifiers: modifiersOf(event),
|
|
83
|
+
}
|
|
84
|
+
: undefined;
|
|
85
|
+
default:
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** True when a press landed on an interactive mesh, which then captures the pointer. */
|
|
91
|
+
export const capturesPointer = (events: ReadonlyArray<SceneEvent>): boolean =>
|
|
92
|
+
events.some((event) => event._tag === 'PointerDown');
|
|
93
|
+
|
|
94
|
+
/** True when the scene handled a context-menu input, so the browser menu should not open. */
|
|
95
|
+
export const claimsContextMenu = (events: ReadonlyArray<SceneEvent>): boolean =>
|
|
96
|
+
events.some((event) => event._tag === 'ContextMenu');
|
|
97
|
+
|
|
98
|
+
const eventTypes = [
|
|
99
|
+
'pointermove',
|
|
100
|
+
'pointerdown',
|
|
101
|
+
'pointerup',
|
|
102
|
+
'pointerleave',
|
|
103
|
+
'pointercancel',
|
|
104
|
+
'dblclick',
|
|
105
|
+
'contextmenu',
|
|
106
|
+
'wheel',
|
|
107
|
+
] as const;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Forwards pointer, mouse and wheel input from `element`. `onInput` returns the scene
|
|
111
|
+
* events it produced. By default the page keeps its gestures: wheel and touch scroll it,
|
|
112
|
+
* and the browser's menu opens except over an interactive mesh that handles it. A press
|
|
113
|
+
* on a mesh captures the pointer.
|
|
114
|
+
*
|
|
115
|
+
* While `claimsGestures()` is true the scene owns them, as three.js's `OrbitControls` does
|
|
116
|
+
* when connected: every press captures the pointer, the wheel does not scroll the page,
|
|
117
|
+
* and the context menu stays shut (the right button pans). Returns a function that
|
|
118
|
+
* removes every listener.
|
|
119
|
+
*/
|
|
120
|
+
export const listenForPointer = (
|
|
121
|
+
element: HTMLElement,
|
|
122
|
+
onInput: (input: PointerInput) => ReadonlyArray<SceneEvent>,
|
|
123
|
+
claimsGestures: () => boolean = () => false,
|
|
124
|
+
): (() => void) => {
|
|
125
|
+
const listener = (event: Event): void => {
|
|
126
|
+
if (!(event instanceof MouseEvent)) return;
|
|
127
|
+
const input = toPointerInput(event, element.getBoundingClientRect());
|
|
128
|
+
if (input === undefined) return;
|
|
129
|
+
const events = onInput(input);
|
|
130
|
+
const claimed = claimsGestures();
|
|
131
|
+
if (claimed && (event.type === 'wheel' || event.type === 'contextmenu')) {
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
}
|
|
134
|
+
if (event.type === 'contextmenu' && claimsContextMenu(events)) event.preventDefault();
|
|
135
|
+
// Keep the drag's moves and release coming here even off the canvas, as React Three
|
|
136
|
+
// Fiber's setPointerCapture does. The browser releases capture on pointer up.
|
|
137
|
+
if (
|
|
138
|
+
event instanceof PointerEvent &&
|
|
139
|
+
event.type === 'pointerdown' &&
|
|
140
|
+
(claimed || capturesPointer(events))
|
|
141
|
+
) {
|
|
142
|
+
element.setPointerCapture(event.pointerId);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
// Not passive, so a claimed wheel can stop the page scrolling; unclaimed wheels are
|
|
146
|
+
// left alone.
|
|
147
|
+
for (const type of eventTypes) element.addEventListener(type, listener, { passive: false });
|
|
148
|
+
return () => {
|
|
149
|
+
for (const type of eventTypes) element.removeEventListener(type, listener);
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** Whatever reports the scene's claim on gestures: its graph. */
|
|
154
|
+
type GestureOwner = { readonly gestures: () => Gestures };
|
|
155
|
+
|
|
156
|
+
/** Whether the scene currently claims the pointer's gestures. */
|
|
157
|
+
export const claimsGestures = (owner: GestureOwner) => (): boolean => owner.gestures() === 'scene';
|
|
158
|
+
|
|
159
|
+
/** Lets the browser pan and zoom on touch, or not while the scene claims gestures. */
|
|
160
|
+
export const touchActionSync = (element: HTMLElement, owner: GestureOwner) => (): void => {
|
|
161
|
+
const touchAction = claimsGestures(owner)() ? 'none' : '';
|
|
162
|
+
if (element.style.touchAction !== touchAction) element.style.touchAction = touchAction;
|
|
163
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-processing for the WebGL renderer: three.js's `EffectComposer`, built from the
|
|
3
|
+
* scene's `render.effects` and rebuilt only when they change. With no effects the scene
|
|
4
|
+
* renders straight to the screen and no composer exists.
|
|
5
|
+
*
|
|
6
|
+
* The composer renders into a multisampled target, so antialiasing survives, and ends
|
|
7
|
+
* with three's `OutputPass`, which applies tone mapping and the output colour space.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
import { effectsEqual, type PostEffect } from '@opsydyn/effect-three';
|
|
12
|
+
import {
|
|
13
|
+
type Camera,
|
|
14
|
+
HalfFloatType,
|
|
15
|
+
PerspectiveCamera,
|
|
16
|
+
type Scene,
|
|
17
|
+
Scene as EmptyScene,
|
|
18
|
+
Vector2,
|
|
19
|
+
type WebGLRenderer,
|
|
20
|
+
WebGLRenderTarget,
|
|
21
|
+
} from 'three';
|
|
22
|
+
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
|
23
|
+
import { FilmPass } from 'three/addons/postprocessing/FilmPass.js';
|
|
24
|
+
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
|
|
25
|
+
import type { Pass } from 'three/addons/postprocessing/Pass.js';
|
|
26
|
+
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
|
27
|
+
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
|
|
28
|
+
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
|
29
|
+
import { VignetteShader } from 'three/addons/shaders/VignetteShader.js';
|
|
30
|
+
|
|
31
|
+
type Size = { width: number; height: number; pixelRatio: number };
|
|
32
|
+
|
|
33
|
+
const passFor = (effect: PostEffect, size: Size): Pass => {
|
|
34
|
+
switch (effect._tag) {
|
|
35
|
+
case 'Bloom':
|
|
36
|
+
return new UnrealBloomPass(
|
|
37
|
+
new Vector2(size.width, size.height),
|
|
38
|
+
effect.strength,
|
|
39
|
+
effect.radius,
|
|
40
|
+
effect.threshold,
|
|
41
|
+
);
|
|
42
|
+
case 'Vignette': {
|
|
43
|
+
const pass = new ShaderPass(VignetteShader);
|
|
44
|
+
pass.uniforms['offset'] = { value: effect.offset };
|
|
45
|
+
pass.uniforms['darkness'] = { value: effect.darkness };
|
|
46
|
+
return pass;
|
|
47
|
+
}
|
|
48
|
+
case 'Film':
|
|
49
|
+
return new FilmPass(effect.intensity, effect.grayscale);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type PostProcessing = {
|
|
54
|
+
/** Rebuilds the passes when `effects` differ from the current ones. */
|
|
55
|
+
readonly configure: (effects: ReadonlyArray<PostEffect>) => void;
|
|
56
|
+
readonly setSize: (width: number, height: number, pixelRatio: number) => void;
|
|
57
|
+
/** Renders through the effects; returns false when there are none to apply. */
|
|
58
|
+
readonly render: (scene: Scene, camera: Camera) => boolean;
|
|
59
|
+
readonly dispose: () => void;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const createPostProcessing = (renderer: WebGLRenderer): PostProcessing => {
|
|
63
|
+
let effects: ReadonlyArray<PostEffect> = [];
|
|
64
|
+
let composer: EffectComposer | undefined;
|
|
65
|
+
let renderPass: RenderPass | undefined;
|
|
66
|
+
const size: Size = { width: 1, height: 1, pixelRatio: 1 };
|
|
67
|
+
|
|
68
|
+
const dispose = (): void => {
|
|
69
|
+
if (composer === undefined) return;
|
|
70
|
+
for (const pass of composer.passes) pass.dispose();
|
|
71
|
+
composer.dispose();
|
|
72
|
+
composer = undefined;
|
|
73
|
+
renderPass = undefined;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const build = (): void => {
|
|
77
|
+
dispose();
|
|
78
|
+
if (effects.length === 0) return;
|
|
79
|
+
const target = new WebGLRenderTarget(size.width, size.height, {
|
|
80
|
+
type: HalfFloatType,
|
|
81
|
+
samples: 4,
|
|
82
|
+
});
|
|
83
|
+
const next = new EffectComposer(renderer, target);
|
|
84
|
+
const first = new RenderPass(new EmptyScene(), new PerspectiveCamera());
|
|
85
|
+
next.addPass(first);
|
|
86
|
+
for (const effect of effects) next.addPass(passFor(effect, size));
|
|
87
|
+
next.addPass(new OutputPass());
|
|
88
|
+
next.setPixelRatio(size.pixelRatio);
|
|
89
|
+
next.setSize(size.width, size.height);
|
|
90
|
+
composer = next;
|
|
91
|
+
renderPass = first;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
configure: (next) => {
|
|
96
|
+
if (effectsEqual(next, effects)) return;
|
|
97
|
+
effects = next;
|
|
98
|
+
build();
|
|
99
|
+
},
|
|
100
|
+
setSize: (width, height, pixelRatio) => {
|
|
101
|
+
size.width = width;
|
|
102
|
+
size.height = height;
|
|
103
|
+
size.pixelRatio = pixelRatio;
|
|
104
|
+
composer?.setPixelRatio(pixelRatio);
|
|
105
|
+
composer?.setSize(width, height);
|
|
106
|
+
},
|
|
107
|
+
render: (scene, camera) => {
|
|
108
|
+
if (composer === undefined || renderPass === undefined) return false;
|
|
109
|
+
renderPass.scene = scene;
|
|
110
|
+
renderPass.camera = camera;
|
|
111
|
+
composer.render();
|
|
112
|
+
return true;
|
|
113
|
+
},
|
|
114
|
+
dispose,
|
|
115
|
+
};
|
|
116
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intercepts the property Foldkit's CustomElement factory sets on every render, so a
|
|
3
|
+
* Mount can see new values without an update hook (ADR-002).
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type PropertyInterception = {
|
|
9
|
+
/** The value present when interception began (set by the first render). */
|
|
10
|
+
readonly initial: unknown;
|
|
11
|
+
/** Restores a plain data property holding the latest value. */
|
|
12
|
+
readonly restore: () => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Calls `onSet` for every assignment to `element[key]`. */
|
|
16
|
+
export const interceptProperty = (
|
|
17
|
+
element: object,
|
|
18
|
+
key: string,
|
|
19
|
+
onSet: (value: unknown) => void,
|
|
20
|
+
): PropertyInterception => {
|
|
21
|
+
const initial: unknown = Reflect.get(element, key);
|
|
22
|
+
let current = initial;
|
|
23
|
+
Object.defineProperty(element, key, {
|
|
24
|
+
configurable: true,
|
|
25
|
+
enumerable: true,
|
|
26
|
+
get: () => current,
|
|
27
|
+
set: (value: unknown) => {
|
|
28
|
+
current = value;
|
|
29
|
+
onSet(value);
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
return {
|
|
33
|
+
initial,
|
|
34
|
+
restore: () => {
|
|
35
|
+
Object.defineProperty(element, key, {
|
|
36
|
+
configurable: true,
|
|
37
|
+
enumerable: true,
|
|
38
|
+
writable: true,
|
|
39
|
+
value: current,
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Summaries of scene updates, for the `scene-reconciled` event.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import type { Op, ResourceStats } from '@opsydyn/effect-three';
|
|
7
|
+
|
|
8
|
+
import type { ReconciledDetail } from '../element.ts';
|
|
9
|
+
|
|
10
|
+
/** Counts operations by kind and snapshots resource counters. */
|
|
11
|
+
export const summarise = (ops: ReadonlyArray<Op>, stats: ResourceStats): ReconciledDetail => {
|
|
12
|
+
const counts = {
|
|
13
|
+
created: 0,
|
|
14
|
+
updated: 0,
|
|
15
|
+
moved: 0,
|
|
16
|
+
removed: 0,
|
|
17
|
+
replaced: 0,
|
|
18
|
+
reordered: 0,
|
|
19
|
+
configured: 0,
|
|
20
|
+
};
|
|
21
|
+
for (const op of ops) {
|
|
22
|
+
switch (op._tag) {
|
|
23
|
+
case 'Create':
|
|
24
|
+
counts.created++;
|
|
25
|
+
break;
|
|
26
|
+
case 'Update':
|
|
27
|
+
counts.updated++;
|
|
28
|
+
break;
|
|
29
|
+
case 'Move':
|
|
30
|
+
counts.moved++;
|
|
31
|
+
break;
|
|
32
|
+
case 'Remove':
|
|
33
|
+
counts.removed++;
|
|
34
|
+
break;
|
|
35
|
+
case 'Replace':
|
|
36
|
+
counts.replaced++;
|
|
37
|
+
break;
|
|
38
|
+
case 'Order':
|
|
39
|
+
counts.reordered++;
|
|
40
|
+
break;
|
|
41
|
+
case 'SetCamera':
|
|
42
|
+
case 'DefineResources':
|
|
43
|
+
counts.configured++;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
ops: counts,
|
|
49
|
+
resources: { geometries: { ...stats.geometries }, materials: { ...stats.materials } },
|
|
50
|
+
};
|
|
51
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the WebGL and WebGPU renderers share: the scene's render settings by three.js's
|
|
3
|
+
* names, recompiling materials when shadows switch, and which renderer draws each
|
|
4
|
+
* canvas, for presenting it in an immersive session.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
import type { RenderSettings } from '@opsydyn/effect-three';
|
|
9
|
+
import {
|
|
10
|
+
ACESFilmicToneMapping,
|
|
11
|
+
AgXToneMapping,
|
|
12
|
+
BasicShadowMap,
|
|
13
|
+
CineonToneMapping,
|
|
14
|
+
type ColorSpace,
|
|
15
|
+
LinearSRGBColorSpace,
|
|
16
|
+
LinearToneMapping,
|
|
17
|
+
Material,
|
|
18
|
+
NeutralToneMapping,
|
|
19
|
+
NoToneMapping,
|
|
20
|
+
type Object3D,
|
|
21
|
+
PCFShadowMap,
|
|
22
|
+
ReinhardToneMapping,
|
|
23
|
+
type ShadowMapType,
|
|
24
|
+
SRGBColorSpace,
|
|
25
|
+
type ToneMapping,
|
|
26
|
+
VSMShadowMap,
|
|
27
|
+
type WebGLRenderer,
|
|
28
|
+
} from 'three';
|
|
29
|
+
import type { WebGPURenderer } from 'three/webgpu';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Shadow map types by React Three Fiber's names (`core/configuration.ts` at `d74622e`).
|
|
33
|
+
* three 0.186 folded PCF soft into PCF (softness is per light), so `soft` maps to PCF
|
|
34
|
+
* here; R3F's PCFSoftShadowMap would only log a removal warning on this version.
|
|
35
|
+
*/
|
|
36
|
+
export const shadowTypes = {
|
|
37
|
+
basic: BasicShadowMap,
|
|
38
|
+
percentage: PCFShadowMap,
|
|
39
|
+
soft: PCFShadowMap,
|
|
40
|
+
variance: VSMShadowMap,
|
|
41
|
+
} satisfies Record<Exclude<RenderSettings['shadows'], 'off'>, ShadowMapType>;
|
|
42
|
+
|
|
43
|
+
export const toneMappings = {
|
|
44
|
+
none: NoToneMapping,
|
|
45
|
+
linear: LinearToneMapping,
|
|
46
|
+
reinhard: ReinhardToneMapping,
|
|
47
|
+
cineon: CineonToneMapping,
|
|
48
|
+
aces: ACESFilmicToneMapping,
|
|
49
|
+
agx: AgXToneMapping,
|
|
50
|
+
neutral: NeutralToneMapping,
|
|
51
|
+
} satisfies Record<RenderSettings['toneMapping'], ToneMapping>;
|
|
52
|
+
|
|
53
|
+
export const colorSpaces = {
|
|
54
|
+
srgb: SRGBColorSpace,
|
|
55
|
+
linear: LinearSRGBColorSpace,
|
|
56
|
+
} satisfies Record<RenderSettings['colorSpace'], ColorSpace>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Flags every material in the scene for recompilation. three.js keeps a shader built
|
|
60
|
+
* with shadows sampling its last shadow map after `shadowMap.enabled` turns off (and
|
|
61
|
+
* without shadows after it turns on) until its material is updated.
|
|
62
|
+
*/
|
|
63
|
+
export const recompileMaterials = (scene: Object3D): void => {
|
|
64
|
+
scene.traverse((object) => {
|
|
65
|
+
const material: unknown = Reflect.get(object, 'material');
|
|
66
|
+
const materials = Array.isArray(material) ? material : [material];
|
|
67
|
+
for (const each of materials) if (each instanceof Material) each.needsUpdate = true;
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** What presenting a renderer in WebXR uses; three's WebGL and WebGPU renderers both have it. */
|
|
72
|
+
export type PresentableRenderer = {
|
|
73
|
+
readonly xr: {
|
|
74
|
+
enabled: boolean;
|
|
75
|
+
readonly isPresenting: boolean;
|
|
76
|
+
setReferenceSpaceType: (type: XRReferenceSpaceType) => void;
|
|
77
|
+
setFramebufferScaleFactor: (scale: number) => void;
|
|
78
|
+
setSession: (session: XRSession) => Promise<void>;
|
|
79
|
+
getReferenceSpace: () => XRReferenceSpace | null;
|
|
80
|
+
setReferenceSpace: (space: XRReferenceSpace) => void;
|
|
81
|
+
getEnvironmentBlendMode: () => XREnvironmentBlendMode | undefined;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Hands the session's frames to `callback` (`null` stops), set before the session starts:
|
|
85
|
+
* WebGL's `xr.setAnimationLoop`, or WebGPU's `setAnimationLoop`, which its XR manager
|
|
86
|
+
* captures when the session starts. It may also be called outside the session.
|
|
87
|
+
*/
|
|
88
|
+
readonly setXrLoop: (callback: ((time: number, frame?: XRFrame) => void) | null) => void;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Each canvas's three.js renderer, for presenting it in a WebXR session. */
|
|
92
|
+
const presentable = new WeakMap<HTMLCanvasElement, PresentableRenderer>();
|
|
93
|
+
/** Each canvas's three.js renderer itself, for tests that read three's own state. */
|
|
94
|
+
const drawing = new WeakMap<HTMLCanvasElement, WebGLRenderer | WebGPURenderer>();
|
|
95
|
+
|
|
96
|
+
export const registerPresentable = (
|
|
97
|
+
canvas: HTMLCanvasElement,
|
|
98
|
+
renderer: WebGLRenderer | WebGPURenderer,
|
|
99
|
+
presenting: PresentableRenderer,
|
|
100
|
+
): void => {
|
|
101
|
+
presentable.set(canvas, presenting);
|
|
102
|
+
drawing.set(canvas, renderer);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const forgetPresentable = (canvas: HTMLCanvasElement): void => {
|
|
106
|
+
presentable.delete(canvas);
|
|
107
|
+
drawing.delete(canvas);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** The three.js renderer drawing into `canvas` (WebGL or WebGPU), if one does. */
|
|
111
|
+
export const threeRendererOf = (
|
|
112
|
+
canvas: HTMLCanvasElement,
|
|
113
|
+
): WebGLRenderer | WebGPURenderer | undefined => drawing.get(canvas);
|
|
114
|
+
|
|
115
|
+
/** The three.js renderer drawing into `canvas`, if one does. */
|
|
116
|
+
export const presentableRendererOf = (canvas: HTMLCanvasElement): PresentableRenderer | undefined =>
|
|
117
|
+
presentable.get(canvas);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scenes that reached the element through the typed `view` helper. TypeScript already
|
|
3
|
+
* checked them against `SceneDescription`, so the Mount can skip the Schema decode (about
|
|
4
|
+
* 0.6 ms per render for 1,000 meshes). Any other value assigned to the element's `scene`
|
|
5
|
+
* property is still decoded.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
import type { SceneDescription } from '@opsydyn/effect-three';
|
|
10
|
+
|
|
11
|
+
const typed = new WeakMap<object, SceneDescription>();
|
|
12
|
+
|
|
13
|
+
/** Records a compile-time-checked scene and returns it unchanged. */
|
|
14
|
+
export const markTyped = (scene: SceneDescription): SceneDescription => {
|
|
15
|
+
typed.set(scene, scene);
|
|
16
|
+
return scene;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** The scene, if this exact value came through `markTyped`. */
|
|
20
|
+
export const typedScene = (value: unknown): SceneDescription | undefined =>
|
|
21
|
+
typeof value === 'object' && value !== null ? typed.get(value) : undefined;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What follows a scene update in the DOM.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** After an update: report it, then run each follow-up step in order. */
|
|
8
|
+
export const afterUpdate =
|
|
9
|
+
<A>(report: (value: A) => () => void, steps: ReadonlyArray<() => void>) =>
|
|
10
|
+
(value: A) =>
|
|
11
|
+
(): void => {
|
|
12
|
+
report(value)();
|
|
13
|
+
for (const step of steps) step();
|
|
14
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Element size, visibility and motion preference.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import type { Viewport } from '@opsydyn/effect-three';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The drawing buffer's pixel ratio: a fixed number, or `[min, max]` to follow the device
|
|
10
|
+
* within bounds. React Three Fiber's `dpr`, with its `[1, 2]` default (`calculateDpr` in
|
|
11
|
+
* `core/utils.tsx` at `d74622e`): ratios above 2 cost GPU time for little visible gain.
|
|
12
|
+
*/
|
|
13
|
+
export type PixelRatio = number | readonly [min: number, max: number];
|
|
14
|
+
|
|
15
|
+
export const defaultPixelRatio: PixelRatio = [1, 2];
|
|
16
|
+
|
|
17
|
+
/** Resolves a pixel ratio setting against the device's ratio. */
|
|
18
|
+
export const resolvePixelRatio = (devicePixelRatio: number, setting: PixelRatio): number =>
|
|
19
|
+
typeof setting === 'number'
|
|
20
|
+
? setting
|
|
21
|
+
: Math.min(Math.max(setting[0], devicePixelRatio), setting[1]);
|
|
22
|
+
|
|
23
|
+
/** The element's current viewport, with the pixel ratio resolved. */
|
|
24
|
+
export const measureViewport = (
|
|
25
|
+
element: Element,
|
|
26
|
+
devicePixelRatio: number,
|
|
27
|
+
pixelRatio: PixelRatio = defaultPixelRatio,
|
|
28
|
+
): Viewport => {
|
|
29
|
+
const rect = element.getBoundingClientRect();
|
|
30
|
+
return {
|
|
31
|
+
width: Math.max(1, rect.width),
|
|
32
|
+
height: Math.max(1, rect.height),
|
|
33
|
+
pixelRatio: resolvePixelRatio(devicePixelRatio, pixelRatio),
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Calls `onResize` whenever the element's size changes. Returns a disconnect function. */
|
|
38
|
+
export const observeSize = (element: Element, onResize: () => void): (() => void) => {
|
|
39
|
+
const observer = new ResizeObserver(onResize);
|
|
40
|
+
observer.observe(element);
|
|
41
|
+
return () => observer.disconnect();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Calls `onVisible` with whether any part of the element is on screen. */
|
|
45
|
+
export const observeVisibility = (
|
|
46
|
+
element: Element,
|
|
47
|
+
onVisible: (visible: boolean) => void,
|
|
48
|
+
): (() => void) => {
|
|
49
|
+
const observer = new IntersectionObserver((entries) => {
|
|
50
|
+
for (const entry of entries) onVisible(entry.isIntersecting);
|
|
51
|
+
});
|
|
52
|
+
observer.observe(element);
|
|
53
|
+
return () => observer.disconnect();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Calls `onChange` with the current reduced-motion preference, then on every change. */
|
|
57
|
+
export const observeReducedMotion = (
|
|
58
|
+
view: Pick<Window, 'matchMedia'>,
|
|
59
|
+
onChange: (reduced: boolean) => void,
|
|
60
|
+
): (() => void) => {
|
|
61
|
+
const query = view.matchMedia('(prefers-reduced-motion: reduce)');
|
|
62
|
+
const listener = (event: MediaQueryListEvent): void => onChange(event.matches);
|
|
63
|
+
onChange(query.matches);
|
|
64
|
+
query.addEventListener('change', listener);
|
|
65
|
+
return () => query.removeEventListener('change', listener);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** The media query calls the pixel-ratio watch needs; `window` provides them. */
|
|
69
|
+
type ChangeTarget = {
|
|
70
|
+
addEventListener(type: 'change', listener: () => void): void;
|
|
71
|
+
removeEventListener(type: 'change', listener: () => void): void;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type PixelRatioHost = {
|
|
75
|
+
readonly devicePixelRatio: number;
|
|
76
|
+
readonly matchMedia: (query: string) => ChangeTarget;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Calls `onChange` whenever the device pixel ratio changes (moving to another display, or
|
|
81
|
+
* zooming), which resizes nothing. As React Three Fiber's `watchDpr`: a media query for
|
|
82
|
+
* the current resolution, re-armed for the new ratio after each change.
|
|
83
|
+
*/
|
|
84
|
+
export const observePixelRatio = (view: PixelRatioHost, onChange: () => void): (() => void) => {
|
|
85
|
+
let query: ChangeTarget | undefined;
|
|
86
|
+
const listener = (): void => {
|
|
87
|
+
query?.removeEventListener('change', listener);
|
|
88
|
+
arm();
|
|
89
|
+
onChange();
|
|
90
|
+
};
|
|
91
|
+
const arm = (): void => {
|
|
92
|
+
query = view.matchMedia(`(resolution: ${view.devicePixelRatio}dppx)`);
|
|
93
|
+
query.addEventListener('change', listener);
|
|
94
|
+
};
|
|
95
|
+
arm();
|
|
96
|
+
return () => query?.removeEventListener('change', listener);
|
|
97
|
+
};
|