@expofp/renderer 3.34.1 → 3.35.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.d.ts +14 -0
- package/dist/Renderer.js +16 -4
- package/dist/core/shader-source.d.ts +20 -0
- package/dist/core/shader-source.js +26 -0
- package/dist/debug/components/Gui.d.ts +2 -0
- package/dist/debug/components/Gui.js +11 -1
- package/dist/external/ExternalCanvas.js +5 -6
- package/dist/external/external-camera.js +26 -11
- package/dist/index.d.ts +1 -0
- package/dist/objects/Background.js +1 -1
- package/dist/objects/containers/SlotContainer.js +7 -1
- package/dist/objects/containers/hooks.js +1 -1
- package/dist/objects/containers/props.d.ts +10 -0
- package/dist/objects/images/Images.js +9 -2
- package/dist/objects/images/image-container.js +1 -1
- package/dist/objects/layer/Layer.js +10 -10
- package/dist/objects/layer/mount-scheduler.d.ts +83 -54
- package/dist/objects/layer/mount-scheduler.js +204 -60
- package/dist/objects/lighting/Lighting.d.ts +92 -0
- package/dist/objects/lighting/Lighting.js +385 -0
- package/dist/objects/lighting/OcclusionPasses.d.ts +23 -0
- package/dist/objects/lighting/OcclusionPasses.js +271 -0
- package/dist/objects/lighting/index.d.ts +11 -0
- package/dist/objects/lighting/index.js +10 -0
- package/dist/objects/lighting/lighting-gui.d.ts +24 -0
- package/dist/objects/lighting/lighting-gui.js +114 -0
- package/dist/objects/lighting/occluder.d.ts +34 -0
- package/dist/objects/lighting/occluder.js +62 -0
- package/dist/objects/lighting/occlusion-targets.d.ts +143 -0
- package/dist/objects/lighting/occlusion-targets.js +276 -0
- package/dist/objects/lines/Lines.js +3 -1
- package/dist/objects/materials/ColorMaterial.d.ts +58 -51
- package/dist/objects/materials/ColorMaterial.js +74 -52
- package/dist/objects/materials/LineMaterial.js +1 -1
- package/dist/objects/materials/ModelMaterial.d.ts +30 -18
- package/dist/objects/materials/ModelMaterial.js +68 -41
- package/dist/objects/materials/TextureMaterial.js +1 -1
- package/dist/objects/materials/effects/ao/ambient-occlusion-pass.d.ts +77 -0
- package/dist/objects/materials/effects/ao/ambient-occlusion-pass.js +296 -0
- package/dist/objects/materials/effects/ao/ambient-occlusion-patch.d.ts +29 -0
- package/dist/objects/materials/effects/ao/ambient-occlusion-patch.js +115 -0
- package/dist/objects/materials/effects/ao/blur-pass.d.ts +61 -0
- package/dist/objects/materials/effects/ao/blur-pass.js +101 -0
- package/dist/objects/materials/effects/ao/index.d.ts +129 -0
- package/dist/objects/materials/effects/ao/index.js +72 -0
- package/dist/objects/materials/{dim.d.ts → effects/color-dim.d.ts} +4 -4
- package/dist/objects/materials/{dim.js → effects/color-dim.js} +6 -24
- package/dist/objects/materials/effects/contact-shadow.d.ts +100 -0
- package/dist/objects/materials/effects/contact-shadow.js +167 -0
- package/dist/objects/materials/effects/effects-context.d.ts +52 -0
- package/dist/objects/materials/effects/effects-context.js +24 -0
- package/dist/objects/models/Models.js +18 -2
- package/dist/objects/models/model-container.js +1 -1
- package/dist/objects/shapes/Shapes.js +20 -2
- package/dist/objects/text/Texts.js +3 -1
- package/dist/objects/text/text-container.js +1 -1
- package/dist/space/Scene.d.ts +3 -0
- package/dist/space/Scene.js +17 -5
- package/package.json +2 -2
- package/dist/objects/Lighting.d.ts +0 -20
- package/dist/objects/Lighting.js +0 -85
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { degToRad } from '@expofp/geometry';
|
|
3
|
+
import { useFrame, useThree } from '@react-three/fiber';
|
|
4
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
5
|
+
import { Color, Vector3, } from 'three';
|
|
6
|
+
import { useSpace } from '../../space/Space';
|
|
7
|
+
import { clamp } from '../../utils/math';
|
|
8
|
+
import { useSceneEffects } from '../materials/effects/effects-context';
|
|
9
|
+
import { useLightingGui } from './lighting-gui';
|
|
10
|
+
/**
|
|
11
|
+
* Scene lighting: a hemisphere fill along the plan's up axis, and a warm directional light at a
|
|
12
|
+
* fixed pitch on the camera's heading. The one intensity control is `contrast`, and `Lighting`
|
|
13
|
+
* derives both `π`-unit intensities from it every frame — so an up-facing surface (a roof) lands on
|
|
14
|
+
* its authored color and no surface the design accounts for clips.
|
|
15
|
+
*
|
|
16
|
+
* That is also why the renderer skips tone mapping (see `Renderer.tsx` and `ExternalCanvas.tsx`): a
|
|
17
|
+
* curve brings an unbounded light sum back into range, and `contrast` keeps this rig's sum in range
|
|
18
|
+
* to begin with, at every pitch.
|
|
19
|
+
*
|
|
20
|
+
* Only lit materials see the lights — solid shape batches and models. Every 2D primitive draws
|
|
21
|
+
* through a `MeshBasicMaterial`, so lines, text and images are unaffected.
|
|
22
|
+
*
|
|
23
|
+
* **The lights sit inside the scene's world matrix**, so every direction below is in the plan's own
|
|
24
|
+
* space, taken there through {@link Space.matrixInverse}. Three reads a light's direction from where
|
|
25
|
+
* it ends up in the *world*, so the two transforms cancel and the light points where it was aimed
|
|
26
|
+
* under any fit. The directional light needs its target under the same matrix for that, since its
|
|
27
|
+
* direction is the difference of two world positions.
|
|
28
|
+
* @param props - The values a caller set, any subset; see {@link LightingProps}.
|
|
29
|
+
* @returns The two lights, and the directional light's target.
|
|
30
|
+
*/
|
|
31
|
+
export function Lighting(props) {
|
|
32
|
+
const { contrast, skyColor, groundColor, directionalColor } = props;
|
|
33
|
+
// Down to the primitives here rather than in the effect below, so the effect runs when a value
|
|
34
|
+
// really moved. A caller writing `lighting={{ shadow: {...} }}` inline hands a fresh object every
|
|
35
|
+
// render, and an object in the dependency list would re-apply and redraw on each of them.
|
|
36
|
+
const { color: shadowColor, contactDistance, contactStrength, ambientOcclusion, aoRadius, aoStrength, } = props.shadow ?? {};
|
|
37
|
+
const fillRef = useRef(null);
|
|
38
|
+
const directionalRef = useRef(null);
|
|
39
|
+
const targetRef = useRef(null);
|
|
40
|
+
const space = useSpace();
|
|
41
|
+
const effects = useSceneEffects();
|
|
42
|
+
const invalidate = useThree((state) => state.invalidate);
|
|
43
|
+
// `useState` rather than `useRef` so the render body may read it, the way `useDimPatch` holds its
|
|
44
|
+
// patch.
|
|
45
|
+
const [settings] = useState(() => ({ ...LIGHT_DEFAULTS }));
|
|
46
|
+
// What each light's color was last set from, so a hex string is parsed on change, not per frame.
|
|
47
|
+
const appliedRef = useRef({ sky: '', ground: '', directional: '' });
|
|
48
|
+
useLayoutEffect(() => {
|
|
49
|
+
directionalRef.current.target = targetRef.current;
|
|
50
|
+
}, []);
|
|
51
|
+
// The one place a prop reaches the two mutable objects the frame and the shaders read. Both are
|
|
52
|
+
// written through a function of this module rather than inline, because the React Compiler treats
|
|
53
|
+
// what a hook returned as frozen for the closure's own body — the discipline `useOccluder` and
|
|
54
|
+
// `OcclusionPasses` keep for their own writes.
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
applyLightValues(settings, { contrast, skyColor, groundColor, directionalColor });
|
|
57
|
+
applyShadowValues(effects, {
|
|
58
|
+
color: shadowColor,
|
|
59
|
+
contactDistance,
|
|
60
|
+
contactStrength,
|
|
61
|
+
ambientOcclusion,
|
|
62
|
+
aoRadius,
|
|
63
|
+
aoStrength,
|
|
64
|
+
});
|
|
65
|
+
// Nothing here re-renders and nothing here draws: the frame reads the rig and the shaders read
|
|
66
|
+
// the uniforms, so a change needs a frame asking for it.
|
|
67
|
+
invalidate();
|
|
68
|
+
}, [
|
|
69
|
+
settings,
|
|
70
|
+
effects,
|
|
71
|
+
invalidate,
|
|
72
|
+
contrast,
|
|
73
|
+
skyColor,
|
|
74
|
+
groundColor,
|
|
75
|
+
directionalColor,
|
|
76
|
+
shadowColor,
|
|
77
|
+
contactDistance,
|
|
78
|
+
contactStrength,
|
|
79
|
+
ambientOcclusion,
|
|
80
|
+
aoRadius,
|
|
81
|
+
aoStrength,
|
|
82
|
+
]);
|
|
83
|
+
// Built here because `Lighting` is mounted once per scene and owns the visual rig. A no-op while
|
|
84
|
+
// the debug UI is off.
|
|
85
|
+
useLightingGui(settings);
|
|
86
|
+
// Runs only on frames something already asked for, and camera movement is what asks for them.
|
|
87
|
+
useFrame((state) => {
|
|
88
|
+
const fill = fillRef.current;
|
|
89
|
+
const directional = directionalRef.current;
|
|
90
|
+
aimDirectionalLight(state.camera, space, settings, directional.position);
|
|
91
|
+
// The plan's up axis, as a point whose world position lands back on it — which is what three
|
|
92
|
+
// reads a hemisphere light's sky axis from. Recomputed rather than memoized, because the space
|
|
93
|
+
// is refitted in place and never changes identity.
|
|
94
|
+
fill.position
|
|
95
|
+
.setFromMatrixColumn(space.matrix, 2)
|
|
96
|
+
.normalize()
|
|
97
|
+
.applyMatrix4(space.matrixInverse);
|
|
98
|
+
// Here rather than as JSX props: the GUI mutates `settings` in place and asks for a redraw, not
|
|
99
|
+
// for a React render, so a prop would hold whatever the value was at mount. `applyColor` parses
|
|
100
|
+
// a hex string only when it changed, so a still rig costs three comparisons a frame.
|
|
101
|
+
applyColor(fill.color, settings.skyColor, appliedRef.current, 'sky');
|
|
102
|
+
applyColor(fill.groundColor, settings.groundColor, appliedRef.current, 'ground');
|
|
103
|
+
applyColor(directional.color, settings.directionalColor, appliedRef.current, 'directional');
|
|
104
|
+
// The pitch as aimed, not `settings.pitchDegrees`: `trackPitch` takes it from the camera instead.
|
|
105
|
+
const pitch = Math.acos(clamp(directional.position.z, -1, 1));
|
|
106
|
+
// Defensive only: the slider never leaves `0`–`1`, but a value set out of band must not hand an
|
|
107
|
+
// over-range contrast to a ceiling that assumes one.
|
|
108
|
+
const contrast = clamp(settings.contrast, 0, 1);
|
|
109
|
+
const { hemisphereIntensity, directionalIntensity } = intensitiesFromContrast(contrast, pitch, fill.color, fill.groundColor, directional.color);
|
|
110
|
+
fill.intensity = hemisphereIntensity * Math.PI;
|
|
111
|
+
directional.intensity = directionalIntensity * Math.PI;
|
|
112
|
+
});
|
|
113
|
+
return (_jsxs(_Fragment, { children: [_jsx("hemisphereLight", { ref: fillRef, color: LIGHT_DEFAULTS.skyColor, groundColor: LIGHT_DEFAULTS.groundColor, intensity: DEFAULT_INTENSITIES.hemisphereIntensity * Math.PI }), _jsx("directionalLight", { ref: directionalRef, color: LIGHT_DEFAULTS.directionalColor, intensity: DEFAULT_INTENSITIES.directionalIntensity * Math.PI }), _jsx("object3D", { ref: targetRef })] }));
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Writes the rig values a caller gave, and leaves every value the caller omitted as it stands.
|
|
117
|
+
*
|
|
118
|
+
* One named write per value rather than an `Object.assign`, because a destructured prop is present
|
|
119
|
+
* and `undefined` when the caller left it out, and assigning that would wipe the default under it.
|
|
120
|
+
* @param settings - The mount's own rig, written in place.
|
|
121
|
+
* @param values - What the caller gave, any subset of it.
|
|
122
|
+
*/
|
|
123
|
+
function applyLightValues(settings, values) {
|
|
124
|
+
if (values.contrast !== undefined)
|
|
125
|
+
settings.contrast = values.contrast;
|
|
126
|
+
if (values.skyColor !== undefined)
|
|
127
|
+
settings.skyColor = values.skyColor;
|
|
128
|
+
if (values.groundColor !== undefined)
|
|
129
|
+
settings.groundColor = values.groundColor;
|
|
130
|
+
if (values.directionalColor !== undefined)
|
|
131
|
+
settings.directionalColor = values.directionalColor;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Writes the darkening values a caller gave onto the scene's own effect state, and leaves every
|
|
135
|
+
* value the caller omitted as it stands.
|
|
136
|
+
*
|
|
137
|
+
* Every write lands on a uniform's `value` or on a plain field a frame callback reads, which is what
|
|
138
|
+
* lets a caller move the shade of a mounted scene with no shader recompile behind it. The lit
|
|
139
|
+
* programs captured these very objects when they were compiled.
|
|
140
|
+
*
|
|
141
|
+
* The shade is written twice on purpose. three holds a `Color` in the working space and a color
|
|
142
|
+
* controller reads a hex string, so the uniform and the string beside it are one value in two forms.
|
|
143
|
+
* @param effects - The mounted scene's own effect state, written in place.
|
|
144
|
+
* @param values - What the caller gave, any subset of it.
|
|
145
|
+
*/
|
|
146
|
+
function applyShadowValues(effects, values) {
|
|
147
|
+
if (values.color !== undefined) {
|
|
148
|
+
effects.shadowHex = values.color;
|
|
149
|
+
effects.shadowColor.value.set(values.color);
|
|
150
|
+
}
|
|
151
|
+
if (values.contactDistance !== undefined)
|
|
152
|
+
effects.shadowDistance.value = values.contactDistance;
|
|
153
|
+
if (values.contactStrength !== undefined)
|
|
154
|
+
effects.shadowStrength.value = values.contactStrength;
|
|
155
|
+
if (values.ambientOcclusion !== undefined)
|
|
156
|
+
effects.enabled = values.ambientOcclusion;
|
|
157
|
+
if (values.aoRadius !== undefined)
|
|
158
|
+
effects.radius = values.aoRadius;
|
|
159
|
+
if (values.aoStrength !== undefined)
|
|
160
|
+
effects.aoStrength.value = values.aoStrength;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Aims the directional light, in the plan's own space, where up is `+z`.
|
|
164
|
+
*
|
|
165
|
+
* With `trackPitch` on it copies the camera's backward axis, which is the pre-overhaul behavior.
|
|
166
|
+
* Otherwise it takes the plan heading from the camera and holds the configured pitch. A fixed pitch
|
|
167
|
+
* pins the exact-albedo surface to the up-facing one, which on a floor plan carries the brand color.
|
|
168
|
+
*
|
|
169
|
+
* **The heading comes from the camera's up axis.** The forward axis points straight down at pitch
|
|
170
|
+
* `0`, the default resting pitch, so its ground projection is zero there and the heading would be
|
|
171
|
+
* undefined. The up axis is the other way round: its ground projection is longest at pitch `0` and
|
|
172
|
+
* shortest at `MAX_PITCH`, where it is still `cos(85°)` of unit length. It never vanishes, so
|
|
173
|
+
* nothing here needs a guard, and the light turns with the plan's heading even from overhead.
|
|
174
|
+
*
|
|
175
|
+
* That reading holds in external mode too, where the camera is a decomposition of the host's own
|
|
176
|
+
* matrix rather than one this renderer placed: `setExternalCameraProjection` measures the roll off
|
|
177
|
+
* that matrix instead of choosing an up axis, precisely so the axis means here what it means under
|
|
178
|
+
* the fit.
|
|
179
|
+
*
|
|
180
|
+
* The ground projection points *against* the heading. At tilt `θ` the camera's up axis is
|
|
181
|
+
* `(-cos θ · heading, sin θ)`, so negating recovers the direction the camera is on.
|
|
182
|
+
* @param camera - The scene camera.
|
|
183
|
+
* @param space - The scene's space, for the plan-to-world mapping.
|
|
184
|
+
* @param settings - The rig, for `trackPitch` and the pitch.
|
|
185
|
+
* @param target - Receives the direction.
|
|
186
|
+
* @returns `target`, unit length.
|
|
187
|
+
*/
|
|
188
|
+
function aimDirectionalLight(camera, space, settings, target) {
|
|
189
|
+
// Columns 1 and 2 of `matrixWorld` are the camera's up and backward axes. Read directly rather
|
|
190
|
+
// than through `Camera.getWorldDirection`, which re-runs `updateWorldMatrix` and leaves
|
|
191
|
+
// `matrixWorldNeedsUpdate` set on every call — which kept the scene dirty and no screenshot ever
|
|
192
|
+
// settled.
|
|
193
|
+
if (settings.trackPitch) {
|
|
194
|
+
return target
|
|
195
|
+
.setFromMatrixColumn(camera.matrixWorld, 2)
|
|
196
|
+
.transformDirection(space.matrixInverse);
|
|
197
|
+
}
|
|
198
|
+
const up = _up.setFromMatrixColumn(camera.matrixWorld, 1).transformDirection(space.matrixInverse);
|
|
199
|
+
const pitch = degToRad(settings.pitchDegrees);
|
|
200
|
+
return target
|
|
201
|
+
.set(-up.x, -up.y, 0)
|
|
202
|
+
.normalize()
|
|
203
|
+
.multiplyScalar(Math.sin(pitch))
|
|
204
|
+
.setZ(Math.cos(pitch));
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Sets a light's color from a hex string, parsing only when the string changed.
|
|
208
|
+
*
|
|
209
|
+
* `Color.set` converts sRGB to the working space, and a string moves only when a color controller
|
|
210
|
+
* writes a new one.
|
|
211
|
+
* @param target - The color to write.
|
|
212
|
+
* @param hex - The CSS hex string to set it from.
|
|
213
|
+
* @param applied - What each color was last set from.
|
|
214
|
+
* @param key - Which entry of `applied` this color owns.
|
|
215
|
+
*/
|
|
216
|
+
function applyColor(target, hex, applied, key) {
|
|
217
|
+
if (applied[key] === hex)
|
|
218
|
+
return;
|
|
219
|
+
target.set(hex);
|
|
220
|
+
applied[key] = hex;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The largest directional intensity (`d`, in `π` units) a wall facing the light can take at this
|
|
224
|
+
* pitch and these colors before some channel of it clips — `contrast = 1`'s own `d`.
|
|
225
|
+
*
|
|
226
|
+
* **No closed form.** {@link intensitiesFromContrast} ties `h` to `d` through a `min` over channels,
|
|
227
|
+
* which folds a channel selection into the middle of the wall's own expression; solving that
|
|
228
|
+
* algebraically means guessing which channel governs *before* knowing `d`, and the answer can change
|
|
229
|
+
* with `d`. Bisecting sidesteps the guess entirely and is exact for any sky, ground and directional
|
|
230
|
+
* color, at the cost of a loop three's own frame budget does not notice.
|
|
231
|
+
*
|
|
232
|
+
* **The search interval's top is where `h` would reach `0`** — pure directional light, nothing from
|
|
233
|
+
* the hemisphere — which is `1 / (max_c(k_c) · cos θ)`. Past it {@link intensitiesFromContrast} would
|
|
234
|
+
* have to hand back a negative hemisphere intensity, which no real light has, so the search never
|
|
235
|
+
* looks there. `litWallPeak` is monotone increasing in `d` once `h(d)` is substituted in (a fact
|
|
236
|
+
* about this rig's own shape, not something this function re-derives), so a single bisection over
|
|
237
|
+
* this interval finds the point where the lit wall reaches exactly `1`.
|
|
238
|
+
*
|
|
239
|
+
* **A shallow pitch may never reach `1` at all** — at or below `45°` a wall never receives more
|
|
240
|
+
* directional light than a roof, so raising `d` only ever pulls the wall *toward* the roof's own
|
|
241
|
+
* value, never past it. Detected by checking the interval's own top rather than by an angle test,
|
|
242
|
+
* so the same check also covers a pitch just above `45°` whose colors still cannot clip a wall within
|
|
243
|
+
* this interval. When that happens the interval's top is `d_max` outright: it is the most directional
|
|
244
|
+
* light the rig can take at all, clipping or not, so it is also the most `contrast = 1` can mean.
|
|
245
|
+
*
|
|
246
|
+
* **The ceiling falls as the pitch steepens past that point**, which reads backward on first glance:
|
|
247
|
+
* a steeper pitch sends relatively more of a fixed `d` into the wall (`sin θ` rising) and relatively
|
|
248
|
+
* less into the roof (`cos θ` falling), so less directional intensity is needed before the wall
|
|
249
|
+
* clips. `contrast = 1` is always the most the rig can take at its own pitch, but "the most" is a
|
|
250
|
+
* smaller number at a grazing pitch than at a gentle one.
|
|
251
|
+
* @param pitchRadians - The directional light's angle from the plan's up axis, as aimed.
|
|
252
|
+
* @param sky - The hemisphere's sky color, linear.
|
|
253
|
+
* @param ground - The hemisphere's ground color, linear.
|
|
254
|
+
* @param directionalColor - The directional light's color, linear.
|
|
255
|
+
* @returns The directional intensity ceiling, in `π` units.
|
|
256
|
+
*/
|
|
257
|
+
function maxDirectional(pitchRadians, sky, ground, directionalColor) {
|
|
258
|
+
const cos = Math.cos(pitchRadians);
|
|
259
|
+
let peakDirectionalColor = 0;
|
|
260
|
+
for (const channel of CHANNELS) {
|
|
261
|
+
peakDirectionalColor = Math.max(peakDirectionalColor, directionalColor[channel]);
|
|
262
|
+
}
|
|
263
|
+
// Guards a grazing or past-vertical pitch, where `cos` is at or below `0`: without this the
|
|
264
|
+
// interval's top would be infinite, negative, or both.
|
|
265
|
+
const ceiling = 1 / Math.max(peakDirectionalColor * cos, MIN_PEAK);
|
|
266
|
+
if (litWallPeak(ceiling, pitchRadians, sky, ground, directionalColor) < 1)
|
|
267
|
+
return ceiling;
|
|
268
|
+
let low = 0;
|
|
269
|
+
let high = ceiling;
|
|
270
|
+
for (let iteration = 0; iteration < BISECTION_ITERATIONS; iteration++) {
|
|
271
|
+
const mid = (low + high) / 2;
|
|
272
|
+
if (litWallPeak(mid, pitchRadians, sky, ground, directionalColor) < 1)
|
|
273
|
+
low = mid;
|
|
274
|
+
else
|
|
275
|
+
high = mid;
|
|
276
|
+
}
|
|
277
|
+
return (low + high) / 2;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* The hemisphere and directional intensities, in `π` units, that pin an up-facing surface (a roof)
|
|
281
|
+
* to exactly the authored sky color on whichever channel would clip first.
|
|
282
|
+
*
|
|
283
|
+
* `d` is `contrast` of the way from `0` to {@link maxDirectional}'s own ceiling for this pitch and
|
|
284
|
+
* these colors — the normalization that gives `contrast` its fixed `0`–`1` range, in place of an
|
|
285
|
+
* intensity ratio whose usable span moved with the pitch. `h` follows by solving three's own roof
|
|
286
|
+
* expression, `h·s + d·k·cos θ`, for the value that lands each channel on exactly `1` and taking the
|
|
287
|
+
* **minimum** across channels: the same "pin the brightest channel, leave the rest a shade under"
|
|
288
|
+
* rule the roof pin has always used, read off in the other direction — the channel needing the least
|
|
289
|
+
* `h` to reach `1` is the one that would otherwise run past it, so giving every channel that same,
|
|
290
|
+
* smaller `h` leaves it exactly on `1` and the others a shade under, which reads as a faint tint
|
|
291
|
+
* rather than a lost highlight, and holds even for a tinted sky.
|
|
292
|
+
* @param contrast - `0` to `1`, already assumed in range.
|
|
293
|
+
* @param pitchRadians - The directional light's angle from the plan's up axis, as aimed.
|
|
294
|
+
* @param sky - The hemisphere's sky color, linear.
|
|
295
|
+
* @param ground - The hemisphere's ground color, linear.
|
|
296
|
+
* @param directionalColor - The directional light's color, linear.
|
|
297
|
+
* @returns The hemisphere and directional intensities, in `π` units.
|
|
298
|
+
*/
|
|
299
|
+
function intensitiesFromContrast(contrast, pitchRadians, sky, ground, directionalColor) {
|
|
300
|
+
const directionalIntensity = contrast * maxDirectional(pitchRadians, sky, ground, directionalColor);
|
|
301
|
+
const hemisphereIntensity = hemisphereForDirectional(directionalIntensity, pitchRadians, sky, directionalColor);
|
|
302
|
+
return { hemisphereIntensity, directionalIntensity };
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* The hemisphere intensity that pins a roof to exactly `1` on its brightest channel, given a
|
|
306
|
+
* directional intensity already chosen.
|
|
307
|
+
*
|
|
308
|
+
* Solving `h·s_c + d·k_c·cos θ = 1` for `h` gives one candidate per channel; the **minimum** of them
|
|
309
|
+
* is what leaves every channel at or under `1` (see {@link intensitiesFromContrast}'s own doc for
|
|
310
|
+
* why the minimum, not the maximum, is the pinning rule stated in terms of `h`).
|
|
311
|
+
* @param directionalIntensity - `d`, in `π` units.
|
|
312
|
+
* @param pitchRadians - The directional light's angle from the plan's up axis, as aimed.
|
|
313
|
+
* @param sky - The hemisphere's sky color, linear.
|
|
314
|
+
* @param directionalColor - The directional light's color, linear.
|
|
315
|
+
* @returns The hemisphere intensity, in `π` units.
|
|
316
|
+
*/
|
|
317
|
+
function hemisphereForDirectional(directionalIntensity, pitchRadians, sky, directionalColor) {
|
|
318
|
+
const cos = Math.cos(pitchRadians);
|
|
319
|
+
let hemisphereIntensity = Infinity;
|
|
320
|
+
for (const channel of CHANNELS) {
|
|
321
|
+
// Guards a black sky channel, which would otherwise divide by zero. A channel that dark can
|
|
322
|
+
// never carry a meaningful hemisphere contribution anyway, so flooring the divisor costs nothing
|
|
323
|
+
// real.
|
|
324
|
+
const skyChannel = Math.max(sky[channel], MIN_SKY);
|
|
325
|
+
hemisphereIntensity = Math.min(hemisphereIntensity, (1 - directionalIntensity * directionalColor[channel] * cos) / skyChannel);
|
|
326
|
+
}
|
|
327
|
+
return hemisphereIntensity;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* The peak channel of a wall facing the light, given a directional intensity already chosen and the
|
|
331
|
+
* hemisphere intensity that pairs with it.
|
|
332
|
+
* @param directionalIntensity - `d`, in `π` units.
|
|
333
|
+
* @param pitchRadians - The directional light's angle from the plan's up axis, as aimed.
|
|
334
|
+
* @param sky - The hemisphere's sky color, linear.
|
|
335
|
+
* @param ground - The hemisphere's ground color, linear.
|
|
336
|
+
* @param directionalColor - The directional light's color, linear.
|
|
337
|
+
* @returns The lit wall's brightest channel.
|
|
338
|
+
*/
|
|
339
|
+
function litWallPeak(directionalIntensity, pitchRadians, sky, ground, directionalColor) {
|
|
340
|
+
const hemisphereIntensity = hemisphereForDirectional(directionalIntensity, pitchRadians, sky, directionalColor);
|
|
341
|
+
const sin = Math.sin(pitchRadians);
|
|
342
|
+
let peak = -Infinity;
|
|
343
|
+
for (const channel of CHANNELS) {
|
|
344
|
+
const wallMix = (ground[channel] + sky[channel]) / 2;
|
|
345
|
+
peak = Math.max(peak, hemisphereIntensity * wallMix + directionalIntensity * directionalColor[channel] * sin);
|
|
346
|
+
}
|
|
347
|
+
return peak;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* The rig this design agreed, and what each mount starts from.
|
|
351
|
+
*
|
|
352
|
+
* `contrast` is `0.7`, measured at the default `60°` pitch with these colors: the roof lands on
|
|
353
|
+
* `1.00`, the lit wall on `0.92`, the wall facing away on `0.66`, the underside on `0.49`. At
|
|
354
|
+
* `contrast = 1` the roof and the lit wall both sit on `1.00`, and the separation section 03 of the
|
|
355
|
+
* design target asks for disappears.
|
|
356
|
+
*/
|
|
357
|
+
const LIGHT_DEFAULTS = {
|
|
358
|
+
trackPitch: false,
|
|
359
|
+
pitchDegrees: 60,
|
|
360
|
+
contrast: 0.7,
|
|
361
|
+
skyColor: '#ffffff',
|
|
362
|
+
groundColor: '#b9c1cc',
|
|
363
|
+
directionalColor: '#fff3e6',
|
|
364
|
+
};
|
|
365
|
+
/** The color channels clipping is decided on, one at a time. */
|
|
366
|
+
const CHANNELS = ['r', 'g', 'b'];
|
|
367
|
+
/** Bisection steps {@link maxDirectional} takes — cheap on a frame, and far past the precision the GUI's own slider step can show. */
|
|
368
|
+
const BISECTION_ITERATIONS = 20;
|
|
369
|
+
/**
|
|
370
|
+
* The smallest sky channel {@link hemisphereForDirectional} divides by, so a black sky channel hands
|
|
371
|
+
* back a large, finite hemisphere intensity rather than `Infinity`.
|
|
372
|
+
*/
|
|
373
|
+
const MIN_SKY = 1e-6;
|
|
374
|
+
/**
|
|
375
|
+
* The smallest `directionalColor · cos θ` peak {@link maxDirectional} divides by, so a grazing or
|
|
376
|
+
* past-vertical pitch hands back a large, finite ceiling rather than `Infinity` or a negative number.
|
|
377
|
+
*/
|
|
378
|
+
const MIN_PEAK = 1e-6;
|
|
379
|
+
/**
|
|
380
|
+
* {@link LIGHT_DEFAULTS}' own intensities, computed once so a tree that mounts and never draws still
|
|
381
|
+
* holds a sensible rig.
|
|
382
|
+
*/
|
|
383
|
+
const DEFAULT_INTENSITIES = intensitiesFromContrast(LIGHT_DEFAULTS.contrast, degToRad(LIGHT_DEFAULTS.pitchDegrees), new Color(LIGHT_DEFAULTS.skyColor), new Color(LIGHT_DEFAULTS.groundColor), new Color(LIGHT_DEFAULTS.directionalColor));
|
|
384
|
+
/** The camera's up axis, reused each frame so aiming allocates nothing. */
|
|
385
|
+
const _up = new Vector3();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Screen-space ambient occlusion, in three passes a frame.
|
|
4
|
+
*
|
|
5
|
+
* The prepass draws {@link OCCLUSION_LAYER} into the one target that carries the depth texture. The
|
|
6
|
+
* AO pass reads that depth and writes how closed in each fragment is. The blur pass reads that
|
|
7
|
+
* output and the same depth, and writes what the lit materials read. Both quad passes bind a target
|
|
8
|
+
* with no depth attachment, because WebGL2 leaves a draw that samples its own framebuffer undefined.
|
|
9
|
+
* See `occlusion-targets.ts` for the three targets and the two textures under them.
|
|
10
|
+
*
|
|
11
|
+
* All three run on a frustum wider than the visible one. Geometry just outside the view then still
|
|
12
|
+
* occludes what is inside it, so no seam of missing darkening runs down the screen edges.
|
|
13
|
+
*
|
|
14
|
+
* Three things switch the passes off, and each one clears `aoActive` first. The debug GUI clears
|
|
15
|
+
* `enabled`, or the `occluders` registry is empty, and both of those free the render targets. A failed
|
|
16
|
+
* `renderTargets.sync()` is a capability gate that frees nothing, because it built nothing.
|
|
17
|
+
*
|
|
18
|
+
* The default frame priority puts this after the camera, which settles at negative priorities. It also
|
|
19
|
+
* puts this before R3F's own draw, so the texture is ready before any material samples it.
|
|
20
|
+
* @returns Nothing. This renders no scene node.
|
|
21
|
+
*/
|
|
22
|
+
export declare function OcclusionPasses(): ReactNode;
|
|
23
|
+
//# sourceMappingURL=OcclusionPasses.d.ts.map
|