@jgengine/core 0.25.0 → 0.27.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 +109 -0
- package/dist/ai/rvo.d.ts +106 -0
- package/dist/ai/rvo.js +581 -0
- package/dist/ai/utilityBrain.d.ts +173 -0
- package/dist/ai/utilityBrain.js +277 -0
- package/dist/combat/cover.d.ts +169 -0
- package/dist/combat/cover.js +264 -0
- package/dist/combat/damageRuntime.d.ts +150 -0
- package/dist/combat/damageRuntime.js +208 -0
- package/dist/combat/damageTable.d.ts +116 -0
- package/dist/combat/damageTable.js +165 -0
- package/dist/combat/hitZones.d.ts +70 -0
- package/dist/combat/hitZones.js +77 -0
- package/dist/combat/magazine.d.ts +8 -0
- package/dist/combat/magazine.js +20 -2
- package/dist/combat/squadCombat.d.ts +167 -0
- package/dist/combat/squadCombat.js +557 -0
- package/dist/combat/squadTactics.d.ts +73 -0
- package/dist/combat/squadTactics.js +49 -0
- package/dist/combat/statusRuntime.d.ts +142 -0
- package/dist/combat/statusRuntime.js +236 -0
- package/dist/combat/weaponDefinition.d.ts +106 -0
- package/dist/combat/weaponDefinition.js +108 -0
- package/dist/combat/weaponSystem.d.ts +189 -0
- package/dist/combat/weaponSystem.js +469 -0
- package/dist/combat.d.ts +9 -0
- package/dist/combat.js +9 -0
- package/dist/devtools/devtools.d.ts +4 -2
- package/dist/devtools/devtools.js +2 -1
- package/dist/devtools/discover.d.ts +2 -1
- package/dist/devtools/discover.js +38 -0
- package/dist/devtools/frame.d.ts +0 -2
- package/dist/devtools/frame.js +1 -2
- package/dist/devtools/logBus.d.ts +58 -0
- package/dist/devtools/logBus.js +79 -0
- package/dist/devtools/logs.d.ts +6 -3
- package/dist/devtools/logs.js +17 -13
- package/dist/devtools/report.d.ts +120 -0
- package/dist/devtools/report.js +49 -0
- package/dist/devtools/reportEndpoint.d.ts +17 -0
- package/dist/devtools/reportEndpoint.js +7 -0
- package/dist/devtools/types.d.ts +48 -11
- package/dist/editor/document.js +210 -0
- package/dist/editor/environment.d.ts +48 -1
- package/dist/editor/environment.js +161 -0
- package/dist/editor/index.d.ts +6 -2
- package/dist/editor/index.js +1 -1
- package/dist/editor/types.d.ts +54 -0
- package/dist/game/playableGame.d.ts +19 -2
- package/dist/gameplay.d.ts +9 -0
- package/dist/gameplay.js +9 -0
- package/dist/inventory/carryLoad.d.ts +48 -0
- package/dist/inventory/carryLoad.js +44 -0
- package/dist/inventory/equipment.d.ts +95 -0
- package/dist/inventory/equipment.js +169 -0
- package/dist/inventory/inventoryModel.d.ts +15 -6
- package/dist/inventory/inventoryModel.js +54 -5
- package/dist/inventory/shapedGrid.d.ts +26 -0
- package/dist/inventory/shapedGrid.js +47 -0
- package/dist/loot/lootGen.d.ts +186 -0
- package/dist/loot/lootGen.js +437 -0
- package/dist/loot/lootWeapon.d.ts +35 -0
- package/dist/loot/lootWeapon.js +70 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +92 -1
- package/dist/nav/laneGraph.d.ts +178 -0
- package/dist/nav/laneGraph.js +671 -0
- package/dist/nav/navCrowd.d.ts +138 -0
- package/dist/nav/navCrowd.js +412 -0
- package/dist/nav/navRuntime.d.ts +108 -0
- package/dist/nav/navRuntime.js +256 -0
- package/dist/nav/traffic.d.ts +223 -0
- package/dist/nav/traffic.js +919 -0
- package/dist/physics/kinematicVehicle.d.ts +36 -0
- package/dist/physics/kinematicVehicle.js +15 -1
- package/dist/physics/vehicleDamage.d.ts +136 -0
- package/dist/physics/vehicleDamage.js +214 -0
- package/dist/physics/vehicleParts.d.ts +62 -0
- package/dist/physics/vehicleParts.js +65 -0
- package/dist/progression/gearScore.d.ts +56 -0
- package/dist/progression/gearScore.js +59 -0
- package/dist/progression/skillTree.d.ts +129 -0
- package/dist/progression/skillTree.js +265 -0
- package/dist/progression/xpCurve.d.ts +60 -0
- package/dist/progression/xpCurve.js +141 -0
- package/dist/render/colorPipeline.d.ts +53 -0
- package/dist/render/colorPipeline.js +79 -0
- package/dist/render/lightProbes.d.ts +97 -0
- package/dist/render/lightProbes.js +221 -0
- package/dist/render/lookPreset.js +4 -4
- package/dist/render/physicalSky.d.ts +39 -0
- package/dist/render/physicalSky.js +89 -0
- package/dist/render/postProcessing.d.ts +40 -8
- package/dist/render/postProcessing.js +1 -1
- package/dist/render/skyOwnedLighting.d.ts +31 -0
- package/dist/render/skyOwnedLighting.js +46 -0
- package/dist/scene/builtinSceneKinds.js +2 -0
- package/dist/sensor/perception.d.ts +66 -5
- package/dist/sensor/perception.js +167 -42
- package/dist/sim/nearAgents.d.ts +68 -0
- package/dist/sim/nearAgents.js +152 -0
- package/dist/sim/needs.d.ts +60 -0
- package/dist/sim/needs.js +108 -0
- package/dist/sim/offScreen.d.ts +181 -0
- package/dist/sim/offScreen.js +450 -0
- package/dist/sim/routine.d.ts +79 -0
- package/dist/sim/routine.js +137 -0
- package/dist/visibility/visibilitySystem.js +21 -0
- package/dist/world/decalKind.d.ts +48 -0
- package/dist/world/decalKind.js +86 -0
- package/dist/world/features.d.ts +11 -1
- package/dist/world/features.js +1 -0
- package/dist/world/grassKind.d.ts +5 -0
- package/dist/world/grassKind.js +3 -0
- package/dist/world/terraform.d.ts +8 -0
- package/dist/world.d.ts +10 -1
- package/dist/world.js +9 -0
- package/package.json +1 -1
- package/skills/jgengine/api.md +59 -6
- package/skills/jgengine/capabilities.md +4 -0
- package/skills/jgengine-combat/SKILL.md +19 -0
- package/skills/jgengine-combat/api.md +246 -0
- package/skills/jgengine-combat/capabilities.md +112 -0
- package/skills/jgengine-combat/reference.md +32 -0
- package/skills/jgengine-gameplay/SKILL.md +39 -0
- package/skills/jgengine-gameplay/api.md +221 -7
- package/skills/jgengine-gameplay/capabilities.md +64 -3
- package/skills/jgengine-gameplay/reference-systems.md +118 -0
- package/skills/jgengine-world/SKILL.md +7 -1
- package/skills/jgengine-world/api.md +222 -8
- package/skills/jgengine-world/capabilities.md +42 -2
- package/skills/jgengine-world/recipes/npc-senses.md +8 -2
- package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calibration of the output pipeline, in linear scene luminance (1.0 = a white-looking surface at
|
|
3
|
+
* exposure 1).
|
|
4
|
+
*
|
|
5
|
+
* `WHITE_POINT` is the luminance the pipeline prints as display white under AgX. Everything above it
|
|
6
|
+
* is highlight headroom the tone curve rolls off, so a material meant to read as white is authored at
|
|
7
|
+
* 1.0 rather than at whatever the sun happened to be set to.
|
|
8
|
+
* `MID_GREY` is an 18% grey card — the metering target. Clamped auto-exposure drives the frame's key
|
|
9
|
+
* luminance here, which is what makes one material read the same in a street at noon and a lamp-lit
|
|
10
|
+
* room. There is one white point and one metering target; a second copy anywhere would make a grade
|
|
11
|
+
* depend on which stage read it.
|
|
12
|
+
*/
|
|
13
|
+
export declare const WHITE_POINT = 1;
|
|
14
|
+
/** Linear scene luminance of an 18% grey card — the auto-exposure metering target. */
|
|
15
|
+
export declare const MID_GREY = 0.18;
|
|
16
|
+
/**
|
|
17
|
+
* Pixels count toward metering up to this multiple of {@link WHITE_POINT}. A window or a sun disc sits
|
|
18
|
+
* far above it and would otherwise pull the whole frame down to a silhouette.
|
|
19
|
+
*/
|
|
20
|
+
export declare const METERING_HIGHLIGHT_CEILING: number;
|
|
21
|
+
/** Bounds and adaptation policy for clamped auto-exposure. */
|
|
22
|
+
export interface AutoExposureConfig {
|
|
23
|
+
/** Lowest exposure multiplier the meter may reach. Default 0.6 (0.7 stops down). */
|
|
24
|
+
min?: number;
|
|
25
|
+
/** Highest exposure multiplier the meter may reach. Default 2.5 (1.3 stops up). */
|
|
26
|
+
max?: number;
|
|
27
|
+
/** Seconds for the meter to cover ~63% of a gap to a new key luminance. Default 0.6. */
|
|
28
|
+
adaptationSeconds?: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Default clamped auto-exposure. The range is deliberately asymmetric: a bright street is already
|
|
32
|
+
* readable after AgX's shoulder, while a lamp-lit interior is the case that crushes, so the meter may
|
|
33
|
+
* lift 1.3 stops but only darken 0.7 — an artist's exposure survives.
|
|
34
|
+
*/
|
|
35
|
+
export declare const DEFAULT_AUTO_EXPOSURE: Readonly<Required<AutoExposureConfig>>;
|
|
36
|
+
/**
|
|
37
|
+
* Exposure that places a frame whose key luminance is `keyLuminance` on {@link MID_GREY}, bounded by
|
|
38
|
+
* the authored range. A dark interior asks for more than `max` and gets `max`; a blown-out exterior
|
|
39
|
+
* asks for less than `min` and gets `min`, so the frame never runs away at either end.
|
|
40
|
+
*/
|
|
41
|
+
export declare function exposureForKey(keyLuminance: number, config?: AutoExposureConfig): number;
|
|
42
|
+
/**
|
|
43
|
+
* Frame-rate independent blend of `current` toward `target`, exponential in time so a hitch does not
|
|
44
|
+
* overshoot and a steady view settles. `deltaSeconds` may be 0..∞; a zero-length step keeps `current`.
|
|
45
|
+
*/
|
|
46
|
+
export declare function adaptExposure(current: number, target: number, deltaSeconds: number, adaptationSeconds?: number): number;
|
|
47
|
+
/**
|
|
48
|
+
* Key luminance of a metered frame: the median of its samples, each clamped to
|
|
49
|
+
* {@link METERING_HIGHLIGHT_CEILING}. A median rather than a mean because a bright sky, a window or a
|
|
50
|
+
* lamp is a large minority of the frame: averaging those in drags an interior down to a silhouette,
|
|
51
|
+
* where the median still reports the room.
|
|
52
|
+
*/
|
|
53
|
+
export declare function keyLuminanceFromSamples(samples: ArrayLike<number>): number;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calibration of the output pipeline, in linear scene luminance (1.0 = a white-looking surface at
|
|
3
|
+
* exposure 1).
|
|
4
|
+
*
|
|
5
|
+
* `WHITE_POINT` is the luminance the pipeline prints as display white under AgX. Everything above it
|
|
6
|
+
* is highlight headroom the tone curve rolls off, so a material meant to read as white is authored at
|
|
7
|
+
* 1.0 rather than at whatever the sun happened to be set to.
|
|
8
|
+
* `MID_GREY` is an 18% grey card — the metering target. Clamped auto-exposure drives the frame's key
|
|
9
|
+
* luminance here, which is what makes one material read the same in a street at noon and a lamp-lit
|
|
10
|
+
* room. There is one white point and one metering target; a second copy anywhere would make a grade
|
|
11
|
+
* depend on which stage read it.
|
|
12
|
+
*/
|
|
13
|
+
export const WHITE_POINT = 1;
|
|
14
|
+
/** Linear scene luminance of an 18% grey card — the auto-exposure metering target. */
|
|
15
|
+
export const MID_GREY = 0.18;
|
|
16
|
+
/**
|
|
17
|
+
* Pixels count toward metering up to this multiple of {@link WHITE_POINT}. A window or a sun disc sits
|
|
18
|
+
* far above it and would otherwise pull the whole frame down to a silhouette.
|
|
19
|
+
*/
|
|
20
|
+
export const METERING_HIGHLIGHT_CEILING = WHITE_POINT * 8;
|
|
21
|
+
/**
|
|
22
|
+
* Default clamped auto-exposure. The range is deliberately asymmetric: a bright street is already
|
|
23
|
+
* readable after AgX's shoulder, while a lamp-lit interior is the case that crushes, so the meter may
|
|
24
|
+
* lift 1.3 stops but only darken 0.7 — an artist's exposure survives.
|
|
25
|
+
*/
|
|
26
|
+
export const DEFAULT_AUTO_EXPOSURE = {
|
|
27
|
+
min: 0.6,
|
|
28
|
+
max: 2.5,
|
|
29
|
+
adaptationSeconds: 0.6,
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Exposure that places a frame whose key luminance is `keyLuminance` on {@link MID_GREY}, bounded by
|
|
33
|
+
* the authored range. A dark interior asks for more than `max` and gets `max`; a blown-out exterior
|
|
34
|
+
* asks for less than `min` and gets `min`, so the frame never runs away at either end.
|
|
35
|
+
*/
|
|
36
|
+
export function exposureForKey(keyLuminance, config = {}) {
|
|
37
|
+
const min = config.min ?? DEFAULT_AUTO_EXPOSURE.min;
|
|
38
|
+
const max = config.max ?? DEFAULT_AUTO_EXPOSURE.max;
|
|
39
|
+
const low = Math.min(min, max);
|
|
40
|
+
const high = Math.max(min, max);
|
|
41
|
+
if (!Number.isFinite(keyLuminance) || keyLuminance <= 0)
|
|
42
|
+
return high;
|
|
43
|
+
return Math.min(high, Math.max(low, MID_GREY / keyLuminance));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Frame-rate independent blend of `current` toward `target`, exponential in time so a hitch does not
|
|
47
|
+
* overshoot and a steady view settles. `deltaSeconds` may be 0..∞; a zero-length step keeps `current`.
|
|
48
|
+
*/
|
|
49
|
+
export function adaptExposure(current, target, deltaSeconds, adaptationSeconds = DEFAULT_AUTO_EXPOSURE.adaptationSeconds) {
|
|
50
|
+
if (!Number.isFinite(target))
|
|
51
|
+
return current;
|
|
52
|
+
if (!(deltaSeconds > 0))
|
|
53
|
+
return current;
|
|
54
|
+
if (!(adaptationSeconds > 0))
|
|
55
|
+
return target;
|
|
56
|
+
const blend = 1 - Math.exp(-deltaSeconds / adaptationSeconds);
|
|
57
|
+
return current + (target - current) * blend;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Key luminance of a metered frame: the median of its samples, each clamped to
|
|
61
|
+
* {@link METERING_HIGHLIGHT_CEILING}. A median rather than a mean because a bright sky, a window or a
|
|
62
|
+
* lamp is a large minority of the frame: averaging those in drags an interior down to a silhouette,
|
|
63
|
+
* where the median still reports the room.
|
|
64
|
+
*/
|
|
65
|
+
export function keyLuminanceFromSamples(samples) {
|
|
66
|
+
if (samples.length === 0)
|
|
67
|
+
return MID_GREY;
|
|
68
|
+
const values = [];
|
|
69
|
+
for (let index = 0; index < samples.length; index += 1) {
|
|
70
|
+
const value = samples[index];
|
|
71
|
+
if (!(value > 0) || !Number.isFinite(value))
|
|
72
|
+
continue;
|
|
73
|
+
values.push(Math.min(value, METERING_HIGHLIGHT_CEILING));
|
|
74
|
+
}
|
|
75
|
+
if (values.length === 0)
|
|
76
|
+
return 0;
|
|
77
|
+
values.sort((a, b) => a - b);
|
|
78
|
+
return values[Math.floor((values.length - 1) / 2)];
|
|
79
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Baked irradiance volume: a grid of second-order (9-coefficient, RGB) spherical-harmonic probes
|
|
3
|
+
* over a box region, sampled at runtime for ambient lighting. It is the datum an interior has that
|
|
4
|
+
* a hemisphere light cannot: a room's fill is its own, not the sky's, and it varies across the space.
|
|
5
|
+
*
|
|
6
|
+
* Serializable and pure; the bake is deterministic heuristics (sky visibility, authored lamps and
|
|
7
|
+
* bounce colours), not a path tracer.
|
|
8
|
+
*/
|
|
9
|
+
/** SH coefficients per channel for the second band — 3 bands, real basis. */
|
|
10
|
+
export declare const SH_COEFFICIENTS = 9;
|
|
11
|
+
/** Floats stored per probe: {@link SH_COEFFICIENTS} × RGB. */
|
|
12
|
+
export declare const PROBE_STRIDE: number;
|
|
13
|
+
/** One irradiance volume authored on a scene document. */
|
|
14
|
+
export interface IrradianceVolume {
|
|
15
|
+
id: string;
|
|
16
|
+
/** World-space centre of the grid. */
|
|
17
|
+
center: readonly [number, number, number];
|
|
18
|
+
/** World-space extent of the grid. */
|
|
19
|
+
size: readonly [number, number, number];
|
|
20
|
+
/** Probe count per axis; at least 1. */
|
|
21
|
+
resolution: readonly [number, number, number];
|
|
22
|
+
/**
|
|
23
|
+
* `PROBE_STRIDE` floats per probe, row-major (x fastest, then y, then z). Absent until baked, so a
|
|
24
|
+
* freshly authored volume renders nothing rather than a fabricated ambient.
|
|
25
|
+
*/
|
|
26
|
+
samples?: readonly number[];
|
|
27
|
+
}
|
|
28
|
+
/** Authored bake inputs stored beside the samples so a volume can be re-baked without the editor. */
|
|
29
|
+
export interface IrradianceBakeSettings {
|
|
30
|
+
/** How much sky reaches the region: 0 fully enclosed, 1 fully open. */
|
|
31
|
+
skyVisibility: number;
|
|
32
|
+
/** Radiance above the horizon for an open region. Default the sky's own horizon colour. */
|
|
33
|
+
skyColor?: string;
|
|
34
|
+
/** Radiance below the horizon (ground bounce). */
|
|
35
|
+
groundColor?: string;
|
|
36
|
+
/** Fill colour of an enclosed region's ceiling; keeps a room from baking to black. */
|
|
37
|
+
ceilingColor?: string;
|
|
38
|
+
/** Fill colour of an enclosed region's floor. */
|
|
39
|
+
floorColor?: string;
|
|
40
|
+
/** Multiplier on the enclosed fill. Default 1. */
|
|
41
|
+
fillIntensity?: number;
|
|
42
|
+
/** Authored lamps folded into the bake. */
|
|
43
|
+
lights?: readonly {
|
|
44
|
+
position: readonly [number, number, number];
|
|
45
|
+
color?: string;
|
|
46
|
+
intensity?: number;
|
|
47
|
+
distance?: number;
|
|
48
|
+
}[];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Real-basis SH values for a unit direction, into `out` (length 9):
|
|
52
|
+
* `[Y00, Y1-1, Y10, Y11, Y2-2, Y2-1, Y20, Y21, Y22]`.
|
|
53
|
+
* @internal
|
|
54
|
+
*/
|
|
55
|
+
export declare function shDirection(x: number, y: number, z: number, out: Float64Array | number[]): void;
|
|
56
|
+
/** World-space position of probe `(ix, iy, iz)`; the outer probes sit on the box faces.
|
|
57
|
+
* @internal
|
|
58
|
+
*/
|
|
59
|
+
export declare function probePosition(volume: Pick<IrradianceVolume, "center" | "size" | "resolution">, ix: number, iy: number, iz: number): [number, number, number];
|
|
60
|
+
/** Everything a bake needs for one volume: where the grid sits, how much sky reaches it, and what fills the dark. */
|
|
61
|
+
export interface IrradianceBakeInput {
|
|
62
|
+
/** Stable volume id, copied onto the baked volume. */
|
|
63
|
+
id: string;
|
|
64
|
+
center: readonly [number, number, number];
|
|
65
|
+
size: readonly [number, number, number];
|
|
66
|
+
resolution: readonly [number, number, number];
|
|
67
|
+
settings: IrradianceBakeSettings;
|
|
68
|
+
/** Direction samples per probe. Default 64. */
|
|
69
|
+
samples?: number;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Bakes an irradiance volume from authored inputs: sky radiance where the region is open, an
|
|
73
|
+
* authored ceiling/floor fill where it is enclosed, plus inverse-square lamp radiance. Each probe
|
|
74
|
+
* projects its directional radiance into L2 SH, so ambient varies across the grid and keeps a lit
|
|
75
|
+
* side instead of flattening to one number.
|
|
76
|
+
* @capability light-probes bake an irradiance volume from authored sky, fill and lamp inputs
|
|
77
|
+
*/
|
|
78
|
+
export declare function bakeIrradianceVolume(input: IrradianceBakeInput): IrradianceVolume;
|
|
79
|
+
/**
|
|
80
|
+
* The volume that governs a world position: the one whose box contains it, else the nearest by
|
|
81
|
+
* centre. A scene usually authors one volume per interior plus one for the exterior shell.
|
|
82
|
+
* @internal
|
|
83
|
+
*/
|
|
84
|
+
export declare function selectIrradianceVolume(volumes: readonly IrradianceVolume[], position: readonly [number, number, number]): IrradianceVolume | undefined;
|
|
85
|
+
/**
|
|
86
|
+
* Trilinearly samples the volume's SH at a world position, writing {@link PROBE_STRIDE} floats into
|
|
87
|
+
* `out`. Positions outside the grid clamp to its faces, so a probe volume never pops to black at its
|
|
88
|
+
* edge. Returns false when the volume has no baked samples.
|
|
89
|
+
* @internal
|
|
90
|
+
*/
|
|
91
|
+
export declare function sampleIrradianceVolume(volume: IrradianceVolume, position: readonly [number, number, number], out: Float32Array): boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Radiance along a direction from the SH, `Σ coeff·basis` clamped at zero. Used by tests and by
|
|
94
|
+
* tooling that previews a volume without a renderer.
|
|
95
|
+
* @internal
|
|
96
|
+
*/
|
|
97
|
+
export declare function radianceAlong(sh: ArrayLike<number>, normal: readonly [number, number, number]): [number, number, number];
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { hexToRgb, mixHex } from "./color.js";
|
|
2
|
+
/**
|
|
3
|
+
* Baked irradiance volume: a grid of second-order (9-coefficient, RGB) spherical-harmonic probes
|
|
4
|
+
* over a box region, sampled at runtime for ambient lighting. It is the datum an interior has that
|
|
5
|
+
* a hemisphere light cannot: a room's fill is its own, not the sky's, and it varies across the space.
|
|
6
|
+
*
|
|
7
|
+
* Serializable and pure; the bake is deterministic heuristics (sky visibility, authored lamps and
|
|
8
|
+
* bounce colours), not a path tracer.
|
|
9
|
+
*/
|
|
10
|
+
/** SH coefficients per channel for the second band — 3 bands, real basis. */
|
|
11
|
+
export const SH_COEFFICIENTS = 9;
|
|
12
|
+
/** Floats stored per probe: {@link SH_COEFFICIENTS} × RGB. */
|
|
13
|
+
export const PROBE_STRIDE = SH_COEFFICIENTS * 3;
|
|
14
|
+
const SH_BAND_0 = 0.282095;
|
|
15
|
+
const SH_BAND_1 = 0.488603;
|
|
16
|
+
const SH_BAND_2_0 = 1.092548;
|
|
17
|
+
const SH_BAND_2_1 = 0.315392;
|
|
18
|
+
const SH_BAND_2_2 = 0.546274;
|
|
19
|
+
/**
|
|
20
|
+
* Real-basis SH values for a unit direction, into `out` (length 9):
|
|
21
|
+
* `[Y00, Y1-1, Y10, Y11, Y2-2, Y2-1, Y20, Y21, Y22]`.
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
export function shDirection(x, y, z, out) {
|
|
25
|
+
out[0] = SH_BAND_0;
|
|
26
|
+
out[1] = SH_BAND_1 * y;
|
|
27
|
+
out[2] = SH_BAND_1 * z;
|
|
28
|
+
out[3] = SH_BAND_1 * x;
|
|
29
|
+
out[4] = SH_BAND_2_0 * x * y;
|
|
30
|
+
out[5] = SH_BAND_2_0 * y * z;
|
|
31
|
+
out[6] = SH_BAND_2_1 * (3 * z * z - 1);
|
|
32
|
+
out[7] = SH_BAND_2_0 * x * z;
|
|
33
|
+
out[8] = SH_BAND_2_2 * (x * x - y * y);
|
|
34
|
+
}
|
|
35
|
+
/** Fibonacci sphere direction `index` of `count`, in the renderer's Y-up frame. */
|
|
36
|
+
function sphereDirection(index, count) {
|
|
37
|
+
const y = 1 - (2 * (index + 0.5)) / count;
|
|
38
|
+
const radius = Math.sqrt(Math.max(0, 1 - y * y));
|
|
39
|
+
const theta = index * 2.399963229728653;
|
|
40
|
+
return [Math.cos(theta) * radius, y, Math.sin(theta) * radius];
|
|
41
|
+
}
|
|
42
|
+
function scaledRgb(color, scale) {
|
|
43
|
+
const [r, g, b] = hexToRgb(color);
|
|
44
|
+
return [(r / 255) * scale, (g / 255) * scale, (b / 255) * scale];
|
|
45
|
+
}
|
|
46
|
+
/** World-space position of probe `(ix, iy, iz)`; the outer probes sit on the box faces.
|
|
47
|
+
* @internal
|
|
48
|
+
*/
|
|
49
|
+
export function probePosition(volume, ix, iy, iz) {
|
|
50
|
+
const [nx, ny, nz] = volume.resolution;
|
|
51
|
+
const t = (index, count) => (count <= 1 ? 0.5 : index / (count - 1));
|
|
52
|
+
return [
|
|
53
|
+
volume.center[0] + (t(ix, nx) - 0.5) * volume.size[0],
|
|
54
|
+
volume.center[1] + (t(iy, ny) - 0.5) * volume.size[1],
|
|
55
|
+
volume.center[2] + (t(iz, nz) - 0.5) * volume.size[2],
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Bakes an irradiance volume from authored inputs: sky radiance where the region is open, an
|
|
60
|
+
* authored ceiling/floor fill where it is enclosed, plus inverse-square lamp radiance. Each probe
|
|
61
|
+
* projects its directional radiance into L2 SH, so ambient varies across the grid and keeps a lit
|
|
62
|
+
* side instead of flattening to one number.
|
|
63
|
+
* @capability light-probes bake an irradiance volume from authored sky, fill and lamp inputs
|
|
64
|
+
*/
|
|
65
|
+
export function bakeIrradianceVolume(input) {
|
|
66
|
+
const { settings } = input;
|
|
67
|
+
const count = Math.max(8, Math.min(256, Math.floor(input.samples ?? 64)));
|
|
68
|
+
const [nx, ny, nz] = input.resolution.map((value) => Math.max(1, Math.floor(value)));
|
|
69
|
+
const visibility = Math.max(0, Math.min(1, settings.skyVisibility));
|
|
70
|
+
const fill = Math.max(0, settings.fillIntensity ?? 1);
|
|
71
|
+
const sky = settings.skyColor ?? "#9ec4e8";
|
|
72
|
+
const ground = settings.groundColor ?? "#4c4a42";
|
|
73
|
+
const ceiling = settings.ceilingColor ?? "#8d8a80";
|
|
74
|
+
const floor = settings.floorColor ?? "#4a4438";
|
|
75
|
+
const lamps = settings.lights ?? [];
|
|
76
|
+
const samples = new Array(PROBE_STRIDE * nx * ny * nz).fill(0);
|
|
77
|
+
const basis = new Float64Array(SH_COEFFICIENTS);
|
|
78
|
+
const accum = new Float64Array(PROBE_STRIDE);
|
|
79
|
+
for (let iz = 0; iz < nz; iz += 1) {
|
|
80
|
+
for (let iy = 0; iy < ny; iy += 1) {
|
|
81
|
+
for (let ix = 0; ix < nx; ix += 1) {
|
|
82
|
+
const position = probePosition({ center: input.center, size: input.size, resolution: [nx, ny, nz] }, ix, iy, iz);
|
|
83
|
+
accum.fill(0);
|
|
84
|
+
for (let s = 0; s < count; s += 1) {
|
|
85
|
+
const [dx, dy, dz] = sphereDirection(s, count);
|
|
86
|
+
shDirection(dx, dy, dz, basis);
|
|
87
|
+
const radiance = radianceIn(position, dx, dy, dz, { visibility, fill, sky, ground, ceiling, floor, lamps });
|
|
88
|
+
for (let band = 0; band < SH_COEFFICIENTS; band += 1) {
|
|
89
|
+
const weighted = basis[band] * (4 * Math.PI) / count;
|
|
90
|
+
accum[band * 3] += radiance[0] * weighted;
|
|
91
|
+
accum[band * 3 + 1] += radiance[1] * weighted;
|
|
92
|
+
accum[band * 3 + 2] += radiance[2] * weighted;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const offset = ((iz * ny + iy) * nx + ix) * PROBE_STRIDE;
|
|
96
|
+
for (let index = 0; index < PROBE_STRIDE; index += 1)
|
|
97
|
+
samples[offset + index] = accum[index];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { id: input.id, center: [...input.center], size: [...input.size], resolution: [nx, ny, nz], samples };
|
|
102
|
+
}
|
|
103
|
+
function radianceIn(position, dx, dy, dz, inputs) {
|
|
104
|
+
const open = inputs.visibility;
|
|
105
|
+
const enclosed = 1 - inputs.visibility;
|
|
106
|
+
let color;
|
|
107
|
+
let scale;
|
|
108
|
+
if (dy >= 0) {
|
|
109
|
+
// Above the horizon an open probe sees sky; an enclosed one sees its own ceiling.
|
|
110
|
+
color = mixHex(inputs.ceiling, inputs.sky, open);
|
|
111
|
+
scale = 0.55 + 0.45 * dy;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
// Below the horizon: ground bounce outside, floor bounce inside.
|
|
115
|
+
color = mixHex(inputs.floor, inputs.ground, open);
|
|
116
|
+
scale = 0.55 + 0.45 * -dy;
|
|
117
|
+
}
|
|
118
|
+
scale *= enclosed * inputs.fill + open;
|
|
119
|
+
const radiance = scaledRgb(color, scale);
|
|
120
|
+
for (const lamp of inputs.lamps) {
|
|
121
|
+
const lx = lamp.position[0] - position[0];
|
|
122
|
+
const ly = lamp.position[1] - position[1];
|
|
123
|
+
const lz = lamp.position[2] - position[2];
|
|
124
|
+
const distanceSquared = lx * lx + ly * ly + lz * lz;
|
|
125
|
+
if (distanceSquared < 1e-4)
|
|
126
|
+
continue;
|
|
127
|
+
const reach = lamp.distance ?? 0;
|
|
128
|
+
if (reach > 0 && distanceSquared > reach * reach)
|
|
129
|
+
continue;
|
|
130
|
+
const distance = Math.sqrt(distanceSquared);
|
|
131
|
+
const facing = (dx * lx + dy * ly + dz * lz) / distance;
|
|
132
|
+
if (facing <= 0)
|
|
133
|
+
continue;
|
|
134
|
+
const falloff = (lamp.intensity ?? 1) / (distanceSquared + 1) * Math.pow(facing, 6);
|
|
135
|
+
const lampRadiant = scaledRgb(lamp.color ?? "#ffd6a0", falloff);
|
|
136
|
+
radiance[0] += lampRadiant[0];
|
|
137
|
+
radiance[1] += lampRadiant[1];
|
|
138
|
+
radiance[2] += lampRadiant[2];
|
|
139
|
+
}
|
|
140
|
+
return radiance;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The volume that governs a world position: the one whose box contains it, else the nearest by
|
|
144
|
+
* centre. A scene usually authors one volume per interior plus one for the exterior shell.
|
|
145
|
+
* @internal
|
|
146
|
+
*/
|
|
147
|
+
export function selectIrradianceVolume(volumes, position) {
|
|
148
|
+
let nearest;
|
|
149
|
+
let nearestDistance = Infinity;
|
|
150
|
+
for (const volume of volumes) {
|
|
151
|
+
if (volume.samples === undefined)
|
|
152
|
+
continue;
|
|
153
|
+
const dx = Math.abs(position[0] - volume.center[0]) - volume.size[0] / 2;
|
|
154
|
+
const dy = Math.abs(position[1] - volume.center[1]) - volume.size[1] / 2;
|
|
155
|
+
const dz = Math.abs(position[2] - volume.center[2]) - volume.size[2] / 2;
|
|
156
|
+
const outside = Math.max(dx, dy, dz);
|
|
157
|
+
if (outside <= 0)
|
|
158
|
+
return volume;
|
|
159
|
+
const distance = Math.hypot(Math.max(dx, 0), Math.max(dy, 0), Math.max(dz, 0));
|
|
160
|
+
if (distance < nearestDistance) {
|
|
161
|
+
nearestDistance = distance;
|
|
162
|
+
nearest = volume;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return nearest;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Trilinearly samples the volume's SH at a world position, writing {@link PROBE_STRIDE} floats into
|
|
169
|
+
* `out`. Positions outside the grid clamp to its faces, so a probe volume never pops to black at its
|
|
170
|
+
* edge. Returns false when the volume has no baked samples.
|
|
171
|
+
* @internal
|
|
172
|
+
*/
|
|
173
|
+
export function sampleIrradianceVolume(volume, position, out) {
|
|
174
|
+
const samples = volume.samples;
|
|
175
|
+
if (samples === undefined || samples.length < PROBE_STRIDE)
|
|
176
|
+
return false;
|
|
177
|
+
const [nx, ny, nz] = volume.resolution;
|
|
178
|
+
const axis = (value, center, size, count) => {
|
|
179
|
+
if (count <= 1 || size === 0)
|
|
180
|
+
return { low: 0, high: 0, t: 0 };
|
|
181
|
+
const normalized = ((value - center) / size + 0.5) * (count - 1);
|
|
182
|
+
const clamped = Math.max(0, Math.min(count - 1, normalized));
|
|
183
|
+
const low = Math.floor(clamped);
|
|
184
|
+
return { low, high: Math.min(count - 1, low + 1), t: clamped - low };
|
|
185
|
+
};
|
|
186
|
+
const x = axis(position[0], volume.center[0], volume.size[0], nx);
|
|
187
|
+
const y = axis(position[1], volume.center[1], volume.size[1], ny);
|
|
188
|
+
const z = axis(position[2], volume.center[2], volume.size[2], nz);
|
|
189
|
+
out.fill(0);
|
|
190
|
+
const weights = [];
|
|
191
|
+
for (const [ix, wx] of [[x.low, 1 - x.t], [x.high, x.t]]) {
|
|
192
|
+
for (const [iy, wy] of [[y.low, 1 - y.t], [y.high, y.t]]) {
|
|
193
|
+
for (const [iz, wz] of [[z.low, 1 - z.t], [z.high, z.t]]) {
|
|
194
|
+
const weight = wx * wy * wz;
|
|
195
|
+
if (weight <= 0)
|
|
196
|
+
continue;
|
|
197
|
+
weights.push([ix, iy, iz]);
|
|
198
|
+
const offset = ((iz * ny + iy) * nx + ix) * PROBE_STRIDE;
|
|
199
|
+
for (let index = 0; index < PROBE_STRIDE; index += 1)
|
|
200
|
+
out[index] += (samples[offset + index] ?? 0) * weight;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return weights.length > 0;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Radiance along a direction from the SH, `Σ coeff·basis` clamped at zero. Used by tests and by
|
|
208
|
+
* tooling that previews a volume without a renderer.
|
|
209
|
+
* @internal
|
|
210
|
+
*/
|
|
211
|
+
export function radianceAlong(sh, normal) {
|
|
212
|
+
const basis = new Float64Array(SH_COEFFICIENTS);
|
|
213
|
+
shDirection(normal[0], normal[1], normal[2], basis);
|
|
214
|
+
const rgb = [0, 0, 0];
|
|
215
|
+
for (let band = 0; band < SH_COEFFICIENTS; band += 1) {
|
|
216
|
+
rgb[0] += (sh[band * 3] ?? 0) * basis[band];
|
|
217
|
+
rgb[1] += (sh[band * 3 + 1] ?? 0) * basis[band];
|
|
218
|
+
rgb[2] += (sh[band * 3 + 2] ?? 0) * basis[band];
|
|
219
|
+
}
|
|
220
|
+
return [Math.max(0, rgb[0]), Math.max(0, rgb[1]), Math.max(0, rgb[2])];
|
|
221
|
+
}
|
|
@@ -4,7 +4,7 @@ export const LOOK_PRESETS = ["neutral", "photoreal", "cinematic", "toon", "comic
|
|
|
4
4
|
export const CINEMATIC_SKY = { preset: "day" };
|
|
5
5
|
/** Tuned tone-map + bloom + gentle SSAO + vignette/grade stack — the shipped-game post look. */
|
|
6
6
|
export const CINEMATIC_POST_PROCESSING = {
|
|
7
|
-
toneMapping: "
|
|
7
|
+
toneMapping: "agx",
|
|
8
8
|
bloom: {},
|
|
9
9
|
ao: { intensity: 1.4 },
|
|
10
10
|
grade: {},
|
|
@@ -17,7 +17,7 @@ export const NEUTRAL_POST_PROCESSING = {
|
|
|
17
17
|
};
|
|
18
18
|
/** High-contrast physically based presentation for authored HDR environments. */
|
|
19
19
|
export const PHOTOREAL_POST_PROCESSING = {
|
|
20
|
-
toneMapping: "
|
|
20
|
+
toneMapping: "agx",
|
|
21
21
|
bloom: { strength: 0.28, radius: 0.58, threshold: 0.9 },
|
|
22
22
|
ao: { intensity: 1.25, radius: 1.6 },
|
|
23
23
|
grade: { saturation: 1.04, vignette: 0.08, grain: 0.004 },
|
|
@@ -31,7 +31,7 @@ export const TOON_POST_PROCESSING = {
|
|
|
31
31
|
};
|
|
32
32
|
/** Ink outlines, four cel bands and a punchy grade — a hand-inked comic look on any model. */
|
|
33
33
|
export const COMIC_POST_PROCESSING = {
|
|
34
|
-
toneMapping: "
|
|
34
|
+
toneMapping: "agx",
|
|
35
35
|
exposure: 1.05,
|
|
36
36
|
aa: "msaa",
|
|
37
37
|
bloom: { strength: 0.12, radius: 0.4, threshold: 1 },
|
|
@@ -41,7 +41,7 @@ export const COMIC_POST_PROCESSING = {
|
|
|
41
41
|
};
|
|
42
42
|
/** Chunky pixels and posterized colour with no smoothing — a low-resolution retro look. */
|
|
43
43
|
export const RETRO_POST_PROCESSING = {
|
|
44
|
-
toneMapping: "
|
|
44
|
+
toneMapping: "agx",
|
|
45
45
|
aa: false,
|
|
46
46
|
bloom: false,
|
|
47
47
|
ao: false,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Peak moonlight as a fraction of the authored sunlight, at the moon's highest point.
|
|
2
|
+
* Real moonlight is ~2.5e-6 of daylight; every game exaggerates it to stay readable, and this is
|
|
3
|
+
* the exaggeration, not a measurement. Games override it through `SkyEnvironmentConfig.moon`. */
|
|
4
|
+
export declare const MOONLIGHT_PEAK = 0.12;
|
|
5
|
+
/** Moonlight's colour: scattered sunlight, blue-shifted because the eye's scotopic response peaks there. */
|
|
6
|
+
export declare const MOONLIGHT_COLOR = "#a9c3e8";
|
|
7
|
+
/** Fog color once the sun is gone: the night sky's own haze, lifted enough to keep silhouettes readable. */
|
|
8
|
+
export declare const NIGHT_FOG_COLOR = "#0a0e16";
|
|
9
|
+
/** Elevation of a world-space direction in degrees; a zero vector reads as overhead. */
|
|
10
|
+
export declare function elevationOf(direction: readonly [number, number, number]): number;
|
|
11
|
+
/**
|
|
12
|
+
* Relative air mass along a line of sight at `elevationDeg` - Kasten & Young (1989). 1 at the
|
|
13
|
+
* zenith, ~38 at the horizon, and clamped below the horizon so a set sun does not run away.
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
16
|
+
export declare function airMass(elevationDeg: number): number;
|
|
17
|
+
/**
|
|
18
|
+
* Fraction of each RGB channel of sunlight that survives the atmosphere at `elevationDeg`:
|
|
19
|
+
* `exp(-τ · air mass)`. High sun keeps nearly all of it; a low sun loses the short wavelengths
|
|
20
|
+
* first, which is the red sunset.
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
export declare function sunTransmission(elevationDeg: number): [number, number, number];
|
|
24
|
+
/**
|
|
25
|
+
* The sun's colour at `elevationDeg`, normalised so its brightest channel is full: white overhead,
|
|
26
|
+
* orange at the horizon, moon-blue once it has set. Intensity stays the authored cycle's job.
|
|
27
|
+
*/
|
|
28
|
+
export declare function sunlightColor(elevationDeg: number): string;
|
|
29
|
+
/** Perceived brightness (Rec. 709 luma) of a `#rrggbb` color, 0–1.
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
export declare function luminanceOf(color: string): number;
|
|
33
|
+
/**
|
|
34
|
+
* Moonlight at `sunElevationDeg`'s opposite point: full at the top of the moon's arc, zero while
|
|
35
|
+
* the moon is below the horizon, scaled by `peak` (a fraction of the authored sun intensity).
|
|
36
|
+
*/
|
|
37
|
+
export declare function moonIllumination(moonElevationDeg: number, peak?: number): number;
|
|
38
|
+
/** How visible the stars are: none in daylight, half at the first hint of a set sun, full once it is 6° under. */
|
|
39
|
+
export declare function starOpacity(sunElevationDeg: number): number;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { rgbToHex, hexToRgb } from "./color.js";
|
|
2
|
+
/**
|
|
3
|
+
* Analytic atmosphere for the day/night sky: Rayleigh air mass, sunlight reddening, moonlight and
|
|
4
|
+
* star visibility. The engine keeps a game's authored sky colors as its noon keyframe; these
|
|
5
|
+
* functions supply the parts a color table cannot — the sun's own tint as it drops toward the
|
|
6
|
+
* horizon, the moon that replaces it after sunset, and how much starlight shows.
|
|
7
|
+
*
|
|
8
|
+
* Pure and deterministic so the whole cycle is testable without a renderer.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Rayleigh scattering coefficient per RGB channel in m^-1 (680, 550, 440 nm, the standard
|
|
12
|
+
* β_R ∝ 1/λ⁴ curve) times an 8000 m scale height — the zenith optical depth `τ` of each channel.
|
|
13
|
+
*/
|
|
14
|
+
const RAYLEIGH_DEPTH = [0.0464, 0.108, 0.2648];
|
|
15
|
+
/** Peak moonlight as a fraction of the authored sunlight, at the moon's highest point.
|
|
16
|
+
* Real moonlight is ~2.5e-6 of daylight; every game exaggerates it to stay readable, and this is
|
|
17
|
+
* the exaggeration, not a measurement. Games override it through `SkyEnvironmentConfig.moon`. */
|
|
18
|
+
export const MOONLIGHT_PEAK = 0.12;
|
|
19
|
+
/** Moonlight's colour: scattered sunlight, blue-shifted because the eye's scotopic response peaks there. */
|
|
20
|
+
export const MOONLIGHT_COLOR = "#a9c3e8";
|
|
21
|
+
/** Fog color once the sun is gone: the night sky's own haze, lifted enough to keep silhouettes readable. */
|
|
22
|
+
export const NIGHT_FOG_COLOR = "#0a0e16";
|
|
23
|
+
const DEG = Math.PI / 180;
|
|
24
|
+
function clamp01(value) {
|
|
25
|
+
return value < 0 ? 0 : value > 1 ? 1 : value;
|
|
26
|
+
}
|
|
27
|
+
function smoothstep(edge0, edge1, value) {
|
|
28
|
+
const t = clamp01((value - edge0) / (edge1 - edge0));
|
|
29
|
+
return t * t * (3 - 2 * t);
|
|
30
|
+
}
|
|
31
|
+
/** Elevation of a world-space direction in degrees; a zero vector reads as overhead. */
|
|
32
|
+
export function elevationOf(direction) {
|
|
33
|
+
const length = Math.hypot(direction[0], direction[1], direction[2]);
|
|
34
|
+
if (length === 0)
|
|
35
|
+
return 90;
|
|
36
|
+
return Math.asin(Math.max(-1, Math.min(1, direction[1] / length))) / DEG;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Relative air mass along a line of sight at `elevationDeg` - Kasten & Young (1989). 1 at the
|
|
40
|
+
* zenith, ~38 at the horizon, and clamped below the horizon so a set sun does not run away.
|
|
41
|
+
* @internal
|
|
42
|
+
*/
|
|
43
|
+
export function airMass(elevationDeg) {
|
|
44
|
+
const zenithDeg = 90 - Math.max(elevationDeg, 0);
|
|
45
|
+
if (zenithDeg >= 90)
|
|
46
|
+
return 38;
|
|
47
|
+
return 1 / (Math.cos(zenithDeg * DEG) + 0.50572 * Math.pow(96.07995 - zenithDeg, -1.6364));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Fraction of each RGB channel of sunlight that survives the atmosphere at `elevationDeg`:
|
|
51
|
+
* `exp(-τ · air mass)`. High sun keeps nearly all of it; a low sun loses the short wavelengths
|
|
52
|
+
* first, which is the red sunset.
|
|
53
|
+
* @internal
|
|
54
|
+
*/
|
|
55
|
+
export function sunTransmission(elevationDeg) {
|
|
56
|
+
const mass = airMass(elevationDeg);
|
|
57
|
+
return [0, 1, 2].map((channel) => Math.exp(-RAYLEIGH_DEPTH[channel] * mass));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The sun's colour at `elevationDeg`, normalised so its brightest channel is full: white overhead,
|
|
61
|
+
* orange at the horizon, moon-blue once it has set. Intensity stays the authored cycle's job.
|
|
62
|
+
*/
|
|
63
|
+
export function sunlightColor(elevationDeg) {
|
|
64
|
+
if (elevationDeg <= -6)
|
|
65
|
+
return MOONLIGHT_COLOR;
|
|
66
|
+
const transmission = sunTransmission(Math.max(elevationDeg, 0));
|
|
67
|
+
const brightest = Math.max(transmission[0], transmission[1], transmission[2], 1e-6);
|
|
68
|
+
return rgbToHex((transmission[0] / brightest) * 255, (transmission[1] / brightest) * 255, (transmission[2] / brightest) * 255);
|
|
69
|
+
}
|
|
70
|
+
/** Perceived brightness (Rec. 709 luma) of a `#rrggbb` color, 0–1.
|
|
71
|
+
* @internal
|
|
72
|
+
*/
|
|
73
|
+
export function luminanceOf(color) {
|
|
74
|
+
const [r, g, b] = hexToRgb(color);
|
|
75
|
+
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Moonlight at `sunElevationDeg`'s opposite point: full at the top of the moon's arc, zero while
|
|
79
|
+
* the moon is below the horizon, scaled by `peak` (a fraction of the authored sun intensity).
|
|
80
|
+
*/
|
|
81
|
+
export function moonIllumination(moonElevationDeg, peak = MOONLIGHT_PEAK) {
|
|
82
|
+
if (moonElevationDeg <= 0)
|
|
83
|
+
return 0;
|
|
84
|
+
return Math.sin(Math.min(moonElevationDeg, 90) * DEG) * Math.max(0, peak);
|
|
85
|
+
}
|
|
86
|
+
/** How visible the stars are: none in daylight, half at the first hint of a set sun, full once it is 6° under. */
|
|
87
|
+
export function starOpacity(sunElevationDeg) {
|
|
88
|
+
return 1 - smoothstep(-6, 0, sunElevationDeg);
|
|
89
|
+
}
|