@waica/engine 0.30.0 → 0.31.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/README.md +48 -3
- package/dist/camera-rig.d.ts +2 -0
- package/dist/camera-rig.js +4 -0
- package/dist/components/animator-blends.d.ts +54 -0
- package/dist/components/animator-blends.js +86 -0
- package/dist/components/animator-mix.d.ts +53 -0
- package/dist/components/animator-mix.js +92 -0
- package/dist/components/animator-params.d.ts +21 -0
- package/dist/components/animator-params.js +50 -0
- package/dist/components/animator-sources.d.ts +59 -0
- package/dist/components/animator-sources.js +125 -0
- package/dist/components/animator.d.ts +84 -10
- package/dist/components/animator.js +163 -47
- package/dist/components/model.d.ts +17 -1
- package/dist/components/model.js +49 -3
- package/dist/components/point-light.d.ts +38 -3
- package/dist/components/point-light.js +6 -3
- package/dist/components/shadow-light.d.ts +63 -0
- package/dist/components/shadow-light.js +71 -0
- package/dist/components/sun.d.ts +38 -3
- package/dist/components/sun.js +6 -3
- package/dist/entity.js +3 -0
- package/dist/game-shadows.d.ts +54 -0
- package/dist/game-shadows.js +260 -0
- package/dist/game.d.ts +3 -0
- package/dist/game.js +15 -2
- package/dist/index.d.ts +7 -4
- package/dist/index.js +3 -1
- package/dist/point-shadow.d.ts +14 -0
- package/dist/point-shadow.js +54 -0
- package/dist/runtime-lighting-snapshot.d.ts +9 -1
- package/dist/runtime-lighting-snapshot.js +24 -4
- package/dist/scene-shadows.d.ts +82 -0
- package/dist/scene-shadows.js +151 -0
- package/dist/scene.d.ts +11 -1
- package/dist/scene.js +2 -1
- package/dist/shadow-renderer.d.ts +8 -0
- package/dist/shadow-renderer.js +37 -0
- package/dist/sun-shadow-camera.d.ts +17 -0
- package/dist/sun-shadow-camera.js +67 -0
- package/package.json +1 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { SceneFieldIssue } from './scene-camera-3d.js';
|
|
2
|
+
import type { ShadowQuality } from './scene.js';
|
|
3
|
+
export type { SceneShadowsJson, ShadowQuality } from './scene.js';
|
|
4
|
+
export interface ResolvedSceneShadows {
|
|
5
|
+
quality: ShadowQuality;
|
|
6
|
+
maxDistance: number;
|
|
7
|
+
}
|
|
8
|
+
export declare const SHADOW_QUALITIES: readonly ShadowQuality[];
|
|
9
|
+
export declare const SHADOW_MAP_SIZES: readonly number[];
|
|
10
|
+
export type ShadowOverrideName = 'shadowMapSize' | 'shadowMaxDistance' | 'shadowBias' | 'shadowNormalBias' | 'shadowRadius';
|
|
11
|
+
export interface LightShadowOverrides {
|
|
12
|
+
castShadows?: boolean;
|
|
13
|
+
shadowMapSize?: number | null;
|
|
14
|
+
shadowMaxDistance?: number | null;
|
|
15
|
+
shadowBias?: number | null;
|
|
16
|
+
shadowNormalBias?: number | null;
|
|
17
|
+
shadowRadius?: number | null;
|
|
18
|
+
}
|
|
19
|
+
export interface ResolvedLightShadow {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
mapSize: number;
|
|
22
|
+
maxDistance: number;
|
|
23
|
+
bias: number;
|
|
24
|
+
normalBias: number;
|
|
25
|
+
radius: number;
|
|
26
|
+
}
|
|
27
|
+
export declare const SHADOW_DEFAULTS: {
|
|
28
|
+
readonly quality: 'medium';
|
|
29
|
+
readonly maxDistance: 40;
|
|
30
|
+
readonly bias: -0.0005;
|
|
31
|
+
readonly normalBias: 0.02;
|
|
32
|
+
readonly radius: 1;
|
|
33
|
+
};
|
|
34
|
+
/** Nullable fields explicitly inherit the scene/preset rather than becoming zero. */
|
|
35
|
+
export declare const SHADOW_OVERRIDE_PARAMS: {
|
|
36
|
+
shadowMapSize: {
|
|
37
|
+
label: string;
|
|
38
|
+
min: number;
|
|
39
|
+
max: number;
|
|
40
|
+
step: number;
|
|
41
|
+
};
|
|
42
|
+
shadowMaxDistance: {
|
|
43
|
+
label: string;
|
|
44
|
+
min: number;
|
|
45
|
+
max: number;
|
|
46
|
+
step: number;
|
|
47
|
+
};
|
|
48
|
+
shadowBias: {
|
|
49
|
+
label: string;
|
|
50
|
+
min: number;
|
|
51
|
+
max: number;
|
|
52
|
+
step: number;
|
|
53
|
+
};
|
|
54
|
+
shadowNormalBias: {
|
|
55
|
+
label: string;
|
|
56
|
+
min: number;
|
|
57
|
+
max: number;
|
|
58
|
+
step: number;
|
|
59
|
+
};
|
|
60
|
+
shadowRadius: {
|
|
61
|
+
label: string;
|
|
62
|
+
min: number;
|
|
63
|
+
max: number;
|
|
64
|
+
step: number;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
/** Shadow flags default on, including malformed authored values; only false opts out. */
|
|
68
|
+
export declare function normalizeShadowBoolean(value: unknown): boolean;
|
|
69
|
+
/** Runtime fallback/clamping only; validators still report invalid authored data. */
|
|
70
|
+
export declare function normalizeShadowOverride(name: ShadowOverrideName, value: unknown): number | null;
|
|
71
|
+
/** Resolves the shadows block itself, independently of lighting or GPU state. */
|
|
72
|
+
export declare function resolveSceneShadows(value: unknown): ResolvedSceneShadows;
|
|
73
|
+
/** Effective policy; a point light's existing distance bounds its independent shadow range. */
|
|
74
|
+
export declare function resolveLightShadow(kind: 'sun' | 'point', scene: ResolvedSceneShadows, overrides: LightShadowOverrides & {
|
|
75
|
+
distance?: number;
|
|
76
|
+
}): ResolvedLightShadow;
|
|
77
|
+
/** Diagnoses authored values, not their runtime-normalized replacements. */
|
|
78
|
+
export declare function sceneShadowIssues(render: unknown): SceneFieldIssue[];
|
|
79
|
+
/** Component-local field paths let callers add their prefab/entity/override context. */
|
|
80
|
+
export declare function shadowComponentIssues(type: string, props: unknown): SceneFieldIssue[];
|
|
81
|
+
/** Check before allocating or replacing a map; Off never needs a supported texture size. */
|
|
82
|
+
export declare function assertShadowMapCapacity(resolved: ResolvedLightShadow, maxTextureSize: number): void;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { resolveSceneSpace } from './scene-space.js';
|
|
2
|
+
export const SHADOW_QUALITIES = ['off', 'low', 'medium', 'high'];
|
|
3
|
+
export const SHADOW_MAP_SIZES = [256, 512, 1024, 2048, 4096];
|
|
4
|
+
export const SHADOW_DEFAULTS = {
|
|
5
|
+
quality: 'medium', maxDistance: 40, bias: -0.0005, normalBias: 0.02, radius: 1,
|
|
6
|
+
};
|
|
7
|
+
const PROFILE_MAP_SIZES = {
|
|
8
|
+
sun: { low: 1024, medium: 2048, high: 4096 },
|
|
9
|
+
point: { low: 256, medium: 512, high: 1024 },
|
|
10
|
+
};
|
|
11
|
+
/** Nullable fields explicitly inherit the scene/preset rather than becoming zero. */
|
|
12
|
+
export const SHADOW_OVERRIDE_PARAMS = {
|
|
13
|
+
shadowMapSize: { label: 'Shadow map size', min: 256, max: 4096, step: 256 },
|
|
14
|
+
shadowMaxDistance: { label: 'Shadow max distance', min: 0.01, max: 1000, step: 0.01 },
|
|
15
|
+
shadowBias: { label: 'Shadow bias', min: -0.1, max: 0.1, step: 0.0001 },
|
|
16
|
+
shadowNormalBias: { label: 'Shadow normal bias', min: 0, max: 1, step: 0.01 },
|
|
17
|
+
shadowRadius: { label: 'Shadow radius', min: 0, max: 8, step: 0.1 },
|
|
18
|
+
};
|
|
19
|
+
function isRecord(value) {
|
|
20
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
function isQuality(value) {
|
|
23
|
+
return SHADOW_QUALITIES.some((quality) => quality === value);
|
|
24
|
+
}
|
|
25
|
+
function isFiniteNumber(value) {
|
|
26
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
27
|
+
}
|
|
28
|
+
function clamp(value, min, max) {
|
|
29
|
+
return Math.min(max, Math.max(min, value));
|
|
30
|
+
}
|
|
31
|
+
/** Shadow flags default on, including malformed authored values; only false opts out. */
|
|
32
|
+
export function normalizeShadowBoolean(value) {
|
|
33
|
+
return value !== false;
|
|
34
|
+
}
|
|
35
|
+
/** Runtime fallback/clamping only; validators still report invalid authored data. */
|
|
36
|
+
export function normalizeShadowOverride(name, value) {
|
|
37
|
+
if (!isFiniteNumber(value))
|
|
38
|
+
return null;
|
|
39
|
+
if (name === 'shadowMapSize')
|
|
40
|
+
return SHADOW_MAP_SIZES.includes(value) ? value : null;
|
|
41
|
+
const { min, max } = SHADOW_OVERRIDE_PARAMS[name];
|
|
42
|
+
return clamp(value, min, max);
|
|
43
|
+
}
|
|
44
|
+
/** Resolves the shadows block itself, independently of lighting or GPU state. */
|
|
45
|
+
export function resolveSceneShadows(value) {
|
|
46
|
+
const block = isRecord(value) ? value : {};
|
|
47
|
+
return {
|
|
48
|
+
quality: isQuality(block.quality) ? block.quality : SHADOW_DEFAULTS.quality,
|
|
49
|
+
maxDistance: normalizeShadowOverride('shadowMaxDistance', block.maxDistance) ?? SHADOW_DEFAULTS.maxDistance,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Effective policy; a point light's existing distance bounds its independent shadow range. */
|
|
53
|
+
export function resolveLightShadow(kind, scene, overrides) {
|
|
54
|
+
const quality = scene.quality === 'off' ? 'medium' : scene.quality;
|
|
55
|
+
const maxDistance = normalizeShadowOverride('shadowMaxDistance', overrides.shadowMaxDistance) ?? scene.maxDistance;
|
|
56
|
+
return {
|
|
57
|
+
enabled: scene.quality !== 'off' && normalizeShadowBoolean(overrides.castShadows),
|
|
58
|
+
mapSize: normalizeShadowOverride('shadowMapSize', overrides.shadowMapSize) ?? PROFILE_MAP_SIZES[kind][quality],
|
|
59
|
+
maxDistance: kind === 'point' ? pointShadowDistance(maxDistance, overrides.distance) : maxDistance,
|
|
60
|
+
bias: normalizeShadowOverride('shadowBias', overrides.shadowBias) ?? SHADOW_DEFAULTS.bias,
|
|
61
|
+
normalBias: normalizeShadowOverride('shadowNormalBias', overrides.shadowNormalBias) ?? SHADOW_DEFAULTS.normalBias,
|
|
62
|
+
radius: normalizeShadowOverride('shadowRadius', overrides.shadowRadius) ?? SHADOW_DEFAULTS.radius,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** A zero/unreadable luminous distance is unlimited, never a zero shadow range. */
|
|
66
|
+
function pointShadowDistance(maxDistance, lightDistance) {
|
|
67
|
+
if (!isFiniteNumber(lightDistance) || lightDistance <= 0)
|
|
68
|
+
return maxDistance;
|
|
69
|
+
return Math.min(maxDistance, lightDistance);
|
|
70
|
+
}
|
|
71
|
+
function shown(value) {
|
|
72
|
+
if (typeof value === 'object' && value !== null) {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.stringify(value) ?? '[unserializable object]';
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return '[unserializable object]';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return typeof value === 'string' ? JSON.stringify(value) : String(value);
|
|
81
|
+
}
|
|
82
|
+
function numericIssue(name, value, field) {
|
|
83
|
+
if (isFiniteNumber(value) && normalizeShadowOverride(name, value) === value)
|
|
84
|
+
return null;
|
|
85
|
+
const { min, max } = SHADOW_OVERRIDE_PARAMS[name];
|
|
86
|
+
const range = name === 'shadowMapSize' ? 'one of 256, 512, 1024, 2048, 4096' : `a finite number from ${min} to ${max}`;
|
|
87
|
+
return { field, message: `${field} must be ${range}; got ${shown(value)}.` };
|
|
88
|
+
}
|
|
89
|
+
/** Diagnoses authored values, not their runtime-normalized replacements. */
|
|
90
|
+
export function sceneShadowIssues(render) {
|
|
91
|
+
if (!isRecord(render) || render.shadows === undefined)
|
|
92
|
+
return [];
|
|
93
|
+
const shadows = render.shadows;
|
|
94
|
+
const issues = [];
|
|
95
|
+
if (resolveSceneSpace(render) !== '3d') {
|
|
96
|
+
issues.push({ field: 'render.shadows', message: "render.shadows needs render.space '3d'; this scene is not 3d." });
|
|
97
|
+
}
|
|
98
|
+
if (!isRecord(shadows)) {
|
|
99
|
+
issues.push({ field: 'render.shadows', message: `render.shadows must be an object; got ${shown(shadows)}.` });
|
|
100
|
+
return issues;
|
|
101
|
+
}
|
|
102
|
+
if (shadows.quality !== undefined && !isQuality(shadows.quality)) {
|
|
103
|
+
issues.push({
|
|
104
|
+
field: 'render.shadows.quality',
|
|
105
|
+
message: `render.shadows.quality must be off, low, medium or high; got ${shown(shadows.quality)}.`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (shadows.maxDistance !== undefined) {
|
|
109
|
+
const issue = numericIssue('shadowMaxDistance', shadows.maxDistance, 'render.shadows.maxDistance');
|
|
110
|
+
if (issue)
|
|
111
|
+
issues.push(issue);
|
|
112
|
+
}
|
|
113
|
+
return issues;
|
|
114
|
+
}
|
|
115
|
+
function booleanIssue(field, value) {
|
|
116
|
+
if (value === undefined || typeof value === 'boolean')
|
|
117
|
+
return null;
|
|
118
|
+
return { field, message: `${field} must be a boolean; got ${shown(value)}.` };
|
|
119
|
+
}
|
|
120
|
+
/** Component-local field paths let callers add their prefab/entity/override context. */
|
|
121
|
+
export function shadowComponentIssues(type, props) {
|
|
122
|
+
if (!isRecord(props) || !['Sun', 'PointLight', 'Model'].includes(type))
|
|
123
|
+
return [];
|
|
124
|
+
const issues = [];
|
|
125
|
+
const castIssue = booleanIssue('castShadows', props.castShadows);
|
|
126
|
+
if (castIssue)
|
|
127
|
+
issues.push(castIssue);
|
|
128
|
+
if (type === 'Model') {
|
|
129
|
+
const receiveIssue = booleanIssue('receiveShadows', props.receiveShadows);
|
|
130
|
+
if (receiveIssue)
|
|
131
|
+
issues.push(receiveIssue);
|
|
132
|
+
return issues;
|
|
133
|
+
}
|
|
134
|
+
for (const name of ['shadowMapSize', 'shadowMaxDistance', 'shadowBias', 'shadowNormalBias', 'shadowRadius']) {
|
|
135
|
+
const value = props[name];
|
|
136
|
+
if (value === undefined || value === null)
|
|
137
|
+
continue;
|
|
138
|
+
const issue = numericIssue(name, value, name);
|
|
139
|
+
if (issue)
|
|
140
|
+
issues.push(issue);
|
|
141
|
+
}
|
|
142
|
+
return issues;
|
|
143
|
+
}
|
|
144
|
+
/** Check before allocating or replacing a map; Off never needs a supported texture size. */
|
|
145
|
+
export function assertShadowMapCapacity(resolved, maxTextureSize) {
|
|
146
|
+
if (!resolved.enabled)
|
|
147
|
+
return;
|
|
148
|
+
if (!isFiniteNumber(maxTextureSize) || resolved.mapSize > maxTextureSize) {
|
|
149
|
+
throw new RangeError(`Shadow map size ${resolved.mapSize} is unsupported by device capacity ${maxTextureSize}.`);
|
|
150
|
+
}
|
|
151
|
+
}
|
package/dist/scene.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SceneCameraJson } from './camera.js';
|
|
2
|
-
import type { ComponentClass } from './component.js';
|
|
2
|
+
import type { Component, ComponentClass } from './component.js';
|
|
3
3
|
import type { Entity } from './entity.js';
|
|
4
4
|
import { type TransformJson } from './entity-transform.js';
|
|
5
5
|
import type { Game } from './game.js';
|
|
@@ -55,6 +55,12 @@ export interface ScenePostJson {
|
|
|
55
55
|
saturation?: number;
|
|
56
56
|
};
|
|
57
57
|
}
|
|
58
|
+
export type ShadowQuality = 'off' | 'low' | 'medium' | 'high';
|
|
59
|
+
/** Scene-wide 3D shadow policy; absent fields use Medium and 40 world units. */
|
|
60
|
+
export interface SceneShadowsJson {
|
|
61
|
+
quality?: ShadowQuality;
|
|
62
|
+
maxDistance?: number;
|
|
63
|
+
}
|
|
58
64
|
/** Scene-wide render options (v3). */
|
|
59
65
|
export interface SceneRenderJson {
|
|
60
66
|
/**
|
|
@@ -65,6 +71,8 @@ export interface SceneRenderJson {
|
|
|
65
71
|
space?: SceneSpace;
|
|
66
72
|
/** Ambient Light; with it (or any Light) the scene is lit (ADR 0026). */
|
|
67
73
|
lighting?: SceneLightingJson;
|
|
74
|
+
/** 3D shadow quality and coverage, independent of Ambient Light. */
|
|
75
|
+
shadows?: SceneShadowsJson;
|
|
68
76
|
/** Vignette and color grade; with either on, the frame renders through a render target. */
|
|
69
77
|
post?: ScenePostJson;
|
|
70
78
|
/**
|
|
@@ -135,6 +143,8 @@ export declare function resolveProps(props: Record<string, unknown> | undefined,
|
|
|
135
143
|
* are never mutated; merged components are fresh objects.
|
|
136
144
|
*/
|
|
137
145
|
export declare function resolveEntityComponents(entity: SceneEntityJson, prefabs?: Record<string, PrefabJson>): SceneComponentJson[];
|
|
146
|
+
/** Scene JSON props with any Component member dropped and reported. */
|
|
147
|
+
export declare function sceneProps(props: Record<string, unknown>, type: string, entityName: string): Partial<Component>;
|
|
138
148
|
export declare function spawnFromJson(game: Game, json: SceneEntityJson, registry: SceneRegistry): Entity;
|
|
139
149
|
/**
|
|
140
150
|
* Loads a full scene into the game, replacing whatever scene is already
|
package/dist/scene.js
CHANGED
|
@@ -83,7 +83,7 @@ function isComponentProps(props) {
|
|
|
83
83
|
return Object.keys(props).every((key) => !Object.hasOwn(COMPONENT_MEMBERS, key));
|
|
84
84
|
}
|
|
85
85
|
/** Scene JSON props with any Component member dropped and reported. */
|
|
86
|
-
function sceneProps(props, type, entityName) {
|
|
86
|
+
export function sceneProps(props, type, entityName) {
|
|
87
87
|
const dropped = Object.keys(props).filter((key) => Object.hasOwn(COMPONENT_MEMBERS, key));
|
|
88
88
|
const kept = Object.fromEntries(Object.entries(props).filter(([key]) => !dropped.includes(key)));
|
|
89
89
|
if (dropped.length > 0) {
|
|
@@ -119,6 +119,7 @@ export function spawnFromJson(game, json, registry) {
|
|
|
119
119
|
* at a time.
|
|
120
120
|
*/
|
|
121
121
|
export function loadScene(game, scene, registry) {
|
|
122
|
+
game.shadows.validateScene(scene, registry);
|
|
122
123
|
if (game.registry) {
|
|
123
124
|
game.unloadScene();
|
|
124
125
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three 0.186 has no public manual-frame pump. Keep this checked compatibility
|
|
3
|
+
* boundary isolated: its RAF frame must not cache shadows/skeletons across
|
|
4
|
+
* several fixed-frame renders. Native RAF updates must never be doubled.
|
|
5
|
+
*/
|
|
6
|
+
export declare function advanceRendererFrame(renderer: object, previous: number | null): number;
|
|
7
|
+
/** Read the initialized backend, never infer device limits from its API name. */
|
|
8
|
+
export declare function shadowTextureLimit(backend: object): number;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
function isRecord(value) {
|
|
2
|
+
return typeof value === 'object' && value !== null;
|
|
3
|
+
}
|
|
4
|
+
function isTextureLimitReader(value) {
|
|
5
|
+
return isRecord(value) && typeof value.MAX_TEXTURE_SIZE === 'number' && typeof value.getParameter === 'function';
|
|
6
|
+
}
|
|
7
|
+
function isRenderFrameClock(value) {
|
|
8
|
+
return isRecord(value) && typeof value.frameId === 'number' && Number.isFinite(value.frameId) && typeof value.update === 'function';
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Three 0.186 has no public manual-frame pump. Keep this checked compatibility
|
|
12
|
+
* boundary isolated: its RAF frame must not cache shadows/skeletons across
|
|
13
|
+
* several fixed-frame renders. Native RAF updates must never be doubled.
|
|
14
|
+
*/
|
|
15
|
+
export function advanceRendererFrame(renderer, previous) {
|
|
16
|
+
const nodes = '_nodes' in renderer ? renderer._nodes : undefined;
|
|
17
|
+
const clock = isRecord(nodes) ? nodes.nodeFrame : undefined;
|
|
18
|
+
if (!isRenderFrameClock(clock))
|
|
19
|
+
throw new Error('3D renderer has no compatible NodeFrame clock (Three 0.186).');
|
|
20
|
+
if (clock.frameId === previous)
|
|
21
|
+
clock.update();
|
|
22
|
+
return clock.frameId;
|
|
23
|
+
}
|
|
24
|
+
function webgpuLimit(backend) {
|
|
25
|
+
const device = 'device' in backend ? backend.device : undefined;
|
|
26
|
+
const limits = isRecord(device) ? device.limits : undefined;
|
|
27
|
+
return isRecord(limits) ? limits.maxTextureDimension2D : undefined;
|
|
28
|
+
}
|
|
29
|
+
function webglLimit(backend) {
|
|
30
|
+
const gl = 'gl' in backend ? backend.gl : undefined;
|
|
31
|
+
return isTextureLimitReader(gl) ? gl.getParameter(gl.MAX_TEXTURE_SIZE) : undefined;
|
|
32
|
+
}
|
|
33
|
+
/** Read the initialized backend, never infer device limits from its API name. */
|
|
34
|
+
export function shadowTextureLimit(backend) {
|
|
35
|
+
const limit = 'isWebGPUBackend' in backend && backend.isWebGPUBackend === true ? webgpuLimit(backend) : webglLimit(backend);
|
|
36
|
+
return typeof limit === 'number' && Number.isFinite(limit) && limit > 0 ? limit : 0;
|
|
37
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
|
+
import type { ResolvedLightShadow } from './scene-shadows.js';
|
|
3
|
+
interface SunShadowFit {
|
|
4
|
+
readonly light: THREE.DirectionalLight;
|
|
5
|
+
readonly camera: THREE.PerspectiveCamera;
|
|
6
|
+
readonly settings: Readonly<ResolvedLightShadow>;
|
|
7
|
+
readonly direction: readonly [number, number, number];
|
|
8
|
+
}
|
|
9
|
+
/** The bounded receiver interval, shared by capacity checks and the camera fit. */
|
|
10
|
+
export declare function sunCoverageDepth(camera: Pick<THREE.PerspectiveCamera, 'near' | 'far'>, maxDistance: number): number | null;
|
|
11
|
+
/**
|
|
12
|
+
* Fits the active view's bounded receiver volume without changing its camera.
|
|
13
|
+
* The caller supplies current view matrices and owns shadow resources/passes.
|
|
14
|
+
* False leaves the previous fit intact; it must not be rendered as coverage.
|
|
15
|
+
*/
|
|
16
|
+
export declare function fitSunShadow({ light, camera, settings, direction }: SunShadowFit): boolean;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
|
+
function receiverSphere(camera, far) {
|
|
3
|
+
const points = [];
|
|
4
|
+
const nearNdc = camera.reversedDepth ? 1 : camera.coordinateSystem === THREE.WebGPUCoordinateSystem ? 0 : -1;
|
|
5
|
+
for (const x of [-1, 1]) {
|
|
6
|
+
for (const y of [-1, 1]) {
|
|
7
|
+
const nearPoint = new THREE.Vector3(x, y, nearNdc).applyMatrix4(camera.projectionMatrixInverse);
|
|
8
|
+
points.push(nearPoint, nearPoint.clone().multiplyScalar(far / -nearPoint.z));
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const center = new THREE.Vector3();
|
|
12
|
+
for (const point of points)
|
|
13
|
+
center.add(point);
|
|
14
|
+
center.multiplyScalar(1 / points.length);
|
|
15
|
+
// Transform offsets, not world positions: translation cannot perturb extent.
|
|
16
|
+
// Unlike Sphere.applyMatrix4, this also bounds sheared camera hierarchies.
|
|
17
|
+
const linear = new THREE.Matrix3().setFromMatrix4(camera.matrixWorld);
|
|
18
|
+
const radius = Math.sqrt(Math.max(...points.map((point) => point.clone().sub(center).applyMatrix3(linear).lengthSq())));
|
|
19
|
+
return new THREE.Sphere(center.applyMatrix4(camera.matrixWorld), radius);
|
|
20
|
+
}
|
|
21
|
+
function snapCenter(sphere, orientation, texelSize) {
|
|
22
|
+
const center = sphere.center.clone().applyMatrix4(orientation.clone().invert());
|
|
23
|
+
center.x = Math.round(center.x / texelSize) * texelSize;
|
|
24
|
+
center.y = Math.round(center.y / texelSize) * texelSize;
|
|
25
|
+
sphere.center.copy(center.applyMatrix4(orientation));
|
|
26
|
+
}
|
|
27
|
+
function placeInWorld(object, position) {
|
|
28
|
+
object.position.copy(position);
|
|
29
|
+
object.parent?.updateWorldMatrix(true, false);
|
|
30
|
+
if (object.parent)
|
|
31
|
+
object.position.applyMatrix4(object.parent.matrixWorld.clone().invert());
|
|
32
|
+
object.updateMatrixWorld();
|
|
33
|
+
}
|
|
34
|
+
/** The bounded receiver interval, shared by capacity checks and the camera fit. */
|
|
35
|
+
export function sunCoverageDepth(camera, maxDistance) {
|
|
36
|
+
const far = Math.min(camera.far, maxDistance);
|
|
37
|
+
return Number.isFinite(camera.near) && camera.near > 0 && far > camera.near ? far : null;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Fits the active view's bounded receiver volume without changing its camera.
|
|
41
|
+
* The caller supplies current view matrices and owns shadow resources/passes.
|
|
42
|
+
* False leaves the previous fit intact; it must not be rendered as coverage.
|
|
43
|
+
*/
|
|
44
|
+
export function fitSunShadow({ light, camera, settings, direction }) {
|
|
45
|
+
const far = sunCoverageDepth(camera, settings.maxDistance);
|
|
46
|
+
if (!settings.enabled || far === null)
|
|
47
|
+
return false;
|
|
48
|
+
light.shadow.mapSize.set(settings.mapSize, settings.mapSize);
|
|
49
|
+
const sphere = receiverSphere(camera, far);
|
|
50
|
+
// One full texel survives the worst-case half-texel center snap.
|
|
51
|
+
sphere.radius /= 1 - 3 / settings.mapSize;
|
|
52
|
+
const travel = new THREE.Vector3(...direction).normalize();
|
|
53
|
+
const shadowCamera = light.shadow.camera;
|
|
54
|
+
// Avoid three's parallel-up fallback, which perturbs the light direction.
|
|
55
|
+
shadowCamera.up.set(0, Math.abs(travel.y) > 0.99 ? 0 : 1, Math.abs(travel.y) > 0.99 ? 1 : 0);
|
|
56
|
+
const orientation = new THREE.Matrix4().lookAt(new THREE.Vector3(), travel, shadowCamera.up);
|
|
57
|
+
snapCenter(sphere, orientation, 2 * sphere.radius / settings.mapSize);
|
|
58
|
+
shadowCamera.left = shadowCamera.bottom = -sphere.radius;
|
|
59
|
+
shadowCamera.right = shadowCamera.top = sphere.radius;
|
|
60
|
+
shadowCamera.near = 0.1;
|
|
61
|
+
shadowCamera.far = 2 * sphere.radius + settings.maxDistance + shadowCamera.near;
|
|
62
|
+
placeInWorld(light.target, sphere.center);
|
|
63
|
+
placeInWorld(light, sphere.center.clone().addScaledVector(travel, -sphere.radius - settings.maxDistance - shadowCamera.near));
|
|
64
|
+
shadowCamera.updateProjectionMatrix();
|
|
65
|
+
light.shadow.updateMatrices(light);
|
|
66
|
+
return true;
|
|
67
|
+
}
|