lecodes-sdk 2.0.12 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/types/gl/Scene.d.ts +13 -28
- package/dist/types/gl/postProcessing.d.ts +228 -0
- package/dist/types/runtime/app.d.ts +30 -1
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +2 -2
- package/prompts/3d-scene.md +5 -2
- package/src/bridges/app.d.ts +18 -1
- package/src/bridges/gl.d.ts +86 -34
- package/src/gl/Scene.ts +15 -40
- package/src/gl/postProcessing.ts +450 -0
- package/src/runtime/app.ts +40 -1
- package/src/server/db/defineDb.ts +1 -0
- package/src/server/db/types.ts +5 -1
- package/src/server/runtime.ts +4 -1
- package/src/version.ts +1 -1
- package/tests/helpers/hostStubs.ts +16 -7
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
// Post-processing of a 3D scene: the look, as ONE object (`scene.postProcessing`,
|
|
2
|
+
// `SceneOptions.postProcessing`) — what Unity's volume and Unreal's post-process volume hold, so it
|
|
3
|
+
// is where a developer looks for it: tone mapping and colour grading, a LUT, bloom, vignette,
|
|
4
|
+
// ambient occlusion, screen-space reflections, depth of field. The scene's own light (ibl,
|
|
5
|
+
// exposureCompensation, fog) and its QUALITY (antialias, renderScale) stay on the scene.
|
|
6
|
+
//
|
|
7
|
+
// The object is read-only on the scene: a field is written (`scene.postProcessing.contrast = 1.1`),
|
|
8
|
+
// several at once with `apply({ ... })` — NAMED FIELDS ONLY, at every level (`apply({ bloom:
|
|
9
|
+
// { intensity: 0.4 } })` leaves the bloom's other knobs and everything else as they are; `false`
|
|
10
|
+
// turns a feature off) — and `reset()` puts every field back to its default.
|
|
11
|
+
//
|
|
12
|
+
// Two bands inside, because the engine treats them differently:
|
|
13
|
+
//
|
|
14
|
+
// - The GRADING band — tone mapping, exposure, white balance, contrast / saturation / vibrance,
|
|
15
|
+
// shadows / midtones / highlights, slope / offset / power, curves, the .cube LUT — is baked by the
|
|
16
|
+
// engine into the ONE 3D lookup table the frame always samples. Free per frame; a CHANGE re-bakes
|
|
17
|
+
// a 32³ table on the CPU. So writes here are COALESCED: a field write marks the band dirty and the
|
|
18
|
+
// whole band goes to the engine once, in a microtask (the end of the current handler), and the
|
|
19
|
+
// values are quantised to 1/1000 so a tween that barely moves does not re-bake. Not a thing to
|
|
20
|
+
// animate every frame; a blend between two looks over a second is fine (a few dozen bakes).
|
|
21
|
+
// - The LIVE band — bloom, vignette, ambient occlusion, screen-space reflections, depth of field —
|
|
22
|
+
// is a set of view options: written to the engine at once, free to animate (a focus pull every
|
|
23
|
+
// frame is what depthOfField is for).
|
|
24
|
+
//
|
|
25
|
+
// The wire of the grading band is a fixed float layout (`CG`), mirrored by creator.h's
|
|
26
|
+
// ColorGradingParam: the engine reads by index, so an index here never moves — a field is added at
|
|
27
|
+
// the end. Overlay scenes take their parent's grading (one look per picture); the live band is per
|
|
28
|
+
// scene.
|
|
29
|
+
|
|
30
|
+
import { Color, type ColorInput } from "../core/color"
|
|
31
|
+
import type { Texture } from "./Texture"
|
|
32
|
+
|
|
33
|
+
export type ToneMappingOperator = "aces" | "neutral" | "linear" | "filmic"
|
|
34
|
+
/** A linear RGB triple. */
|
|
35
|
+
export type Rgb = readonly [number, number, number]
|
|
36
|
+
export type Quality = "low" | "medium" | "high" | "ultra"
|
|
37
|
+
|
|
38
|
+
export type BloomOptions = {
|
|
39
|
+
/** How much of the blurred highlights is added back, 0..1 (default 0.2). */
|
|
40
|
+
intensity?: number
|
|
41
|
+
/** Only what is brighter than 1.0 after exposure blooms (default true); false makes everything
|
|
42
|
+
* glow a little — the soft "dreamy" look. */
|
|
43
|
+
threshold?: boolean
|
|
44
|
+
/** Clamp of one pixel's contribution (default 1000); lower it when a few emissive pixels flicker. */
|
|
45
|
+
highlight?: number
|
|
46
|
+
/** Depth of the blur chain, 1..12 (default 6): how far the glow spreads. */
|
|
47
|
+
levels?: number
|
|
48
|
+
/** The blur's sample count (default 'medium'). */
|
|
49
|
+
quality?: Quality
|
|
50
|
+
/** Ghosts + halo around the brightest spots (default false). */
|
|
51
|
+
lensFlare?: boolean
|
|
52
|
+
/** A "dirty lens": this texture multiplied into the bloom (default none). */
|
|
53
|
+
dirt?: Texture | null
|
|
54
|
+
/** How much of the dirt shows, 0..1 (default 0.2). */
|
|
55
|
+
dirtStrength?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type VignetteOptions = {
|
|
59
|
+
/** How far from the centre the darkening starts, 0..1 (default 0.5). */
|
|
60
|
+
midPoint?: number
|
|
61
|
+
/** 0 follows the aspect ratio, 1 is a circle (default 0.5). */
|
|
62
|
+
roundness?: number
|
|
63
|
+
/** Softness of the edge, 0..1 (default 0.5). */
|
|
64
|
+
feather?: number
|
|
65
|
+
/** The colour at the edge (default black). */
|
|
66
|
+
color?: ColorInput
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Screen-space ambient occlusion — the contact darkening in creases and where props meet the
|
|
70
|
+
* ground. Without it an IBL lights a crease exactly as brightly as an open face, so everything
|
|
71
|
+
* reads as pasted onto the floor rather than standing on it. `true` takes defaults tuned for
|
|
72
|
+
* human-scale props; `radius` is world-space metres and is the one knob that must follow the
|
|
73
|
+
* scene's scale (~0.3 for objects on a table, ~0.6-1 for a yard of crates and containers). */
|
|
74
|
+
export type AmbientOcclusionOptions = {
|
|
75
|
+
/** Strength of the darkening (default 1). */
|
|
76
|
+
intensity?: number
|
|
77
|
+
/** How far the occlusion reaches, in metres (default 0.3). */
|
|
78
|
+
radius?: number
|
|
79
|
+
/** Falloff contrast; >1 tightens it into the crease (default 1). */
|
|
80
|
+
power?: number
|
|
81
|
+
/** Sample count + filtering (default 'medium'). Not the buffer resolution — that stays half. */
|
|
82
|
+
quality?: Quality
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Screen-space reflections: glossy surfaces reflect what is ON SCREEN, by a ray march through the
|
|
86
|
+
* depth buffer (a wet floor reflecting the lamps above it). Nothing off screen reflects and a
|
|
87
|
+
* reflection ends at the frame's edge — the classic limit. A per-pixel ray: a real cost on a
|
|
88
|
+
* phone, not measured yet. */
|
|
89
|
+
export type ScreenSpaceReflectionsOptions = {
|
|
90
|
+
/** Metres a ray travels before giving up (default 3). */
|
|
91
|
+
maxDistance?: number
|
|
92
|
+
/** Metres a surface is assumed to extend behind its depth — a hit inside it counts (default 0.1). */
|
|
93
|
+
thickness?: number
|
|
94
|
+
/** Pixels per ray step: 1 = exact and slow, 2..4 the usual (default 2). */
|
|
95
|
+
stride?: number
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Depth of field: the camera focuses at `focusDistance` and everything else blurs as the lens
|
|
99
|
+
* would — as if it were at f/`aperture`, WITHOUT touching the exposure (the camera's own aperture,
|
|
100
|
+
* f/16 by default, would show next to no blur; the engine scales the blur instead of opening the
|
|
101
|
+
* lens). The physics are honest and therefore mild: a game camera is a WIDE lens (fov 60° is a
|
|
102
|
+
* 21 mm lens), and a wide lens at f/1.4 still blurs a prop 9 m behind a 2.5 m focus by only ~2 px
|
|
103
|
+
* of 600 — `strength` multiplies the blur for the look a 50 mm lens would give (3-4 reads as
|
|
104
|
+
* "cinematic" at fov 60°). All live: a focus pull is a `focusDistance` written every frame. */
|
|
105
|
+
export type DepthOfFieldOptions = {
|
|
106
|
+
/** Metres from the camera to the plane in focus (default 10). */
|
|
107
|
+
focusDistance?: number
|
|
108
|
+
/** The f-number the blur should look like (default 2.8; smaller = shallower). */
|
|
109
|
+
aperture?: number
|
|
110
|
+
/** Multiplier on the blur (default 1 = the physics of the camera's lens). */
|
|
111
|
+
strength?: number
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type WhiteBalance = {
|
|
115
|
+
/** Blue / yellow axis, -1 (cool, 50 000 K) .. 1 (warm, 2 000 K); 0 = none. */
|
|
116
|
+
temperature?: number
|
|
117
|
+
/** Green / magenta axis, -1 .. 1; 0 = none. */
|
|
118
|
+
tint?: number
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Where the tonal zones of `shadows` / `midtones` / `highlights` fade into each other, in 0..1 of
|
|
122
|
+
* the (linear) luminance: `shadows` = (start, end) of the shadows → midtones fade (default 0, 0.333),
|
|
123
|
+
* `highlights` = (start, end) of the midtones → highlights fade (default 0.55, 1). */
|
|
124
|
+
export type TonalRanges = {
|
|
125
|
+
shadows?: readonly [number, number]
|
|
126
|
+
highlights?: readonly [number, number]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Per-channel curves: a gamma on the shadows, the point where shadows stop and highlights start,
|
|
130
|
+
* and a scale on the highlights — each per RGB channel, `[1, 1, 1]` = none. */
|
|
131
|
+
export type ColorCurves = {
|
|
132
|
+
shadowGamma?: Rgb
|
|
133
|
+
midPoint?: Rgb
|
|
134
|
+
highlightScale?: Rgb
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type PostProcessingOptions = {
|
|
138
|
+
/** The operator that folds the scene's HDR light into the display's range (default 'aces').
|
|
139
|
+
* `'aces'` (filament's ACES legacy) desaturates bright colours towards white — HDR fire reads
|
|
140
|
+
* pale; `'neutral'` (Khronos PBR Neutral) keeps hue and saturation until very bright; `'linear'`
|
|
141
|
+
* clips each channel (what an engine without a tonemapper shows — saturated, Unity-without-post
|
|
142
|
+
* look); `'filmic'` is the Uncharted curve. */
|
|
143
|
+
toneMapping?: ToneMappingOperator
|
|
144
|
+
/** Post-exposure in stops, applied AFTER bloom to the finished picture (default 0). Not the
|
|
145
|
+
* camera's `exposureCompensation`, which scales the light itself — what bloom and fog see. */
|
|
146
|
+
exposure?: number
|
|
147
|
+
whiteBalance?: WhiteBalance
|
|
148
|
+
/** 0..2, 1 = none. Applied in log space. */
|
|
149
|
+
contrast?: number
|
|
150
|
+
/** 0..2, 1 = none. */
|
|
151
|
+
saturation?: number
|
|
152
|
+
/** Saturation that spares what is already saturated, 0..2, 1 = none. */
|
|
153
|
+
vibrance?: number
|
|
154
|
+
/** Linear RGB multiplier of the shadows zone, `[1, 1, 1]` = none. */
|
|
155
|
+
shadows?: Rgb
|
|
156
|
+
midtones?: Rgb
|
|
157
|
+
highlights?: Rgb
|
|
158
|
+
ranges?: TonalRanges
|
|
159
|
+
/** ASC CDL — the lift / gamma / gain of a grading suite, per channel, applied in log space:
|
|
160
|
+
* `slope` multiplies (gain; `[1, 1, 1]` = none), `offset` adds (lift; `[0, 0, 0]` = none),
|
|
161
|
+
* `power` is the exponent (gamma; `[1, 1, 1]` = none). */
|
|
162
|
+
slope?: Rgb
|
|
163
|
+
offset?: Rgb
|
|
164
|
+
power?: Rgb
|
|
165
|
+
curves?: ColorCurves
|
|
166
|
+
/** A 3D LUT from a grading tool (Resolve, Photoshop, Unity's Color Lookup) applied last, after
|
|
167
|
+
* the operator, in display-referred sRGB — a `.cube` file: `asset('./look.cube')`, or a bare
|
|
168
|
+
* staged filename. `null` removes it. */
|
|
169
|
+
lut?: string | null
|
|
170
|
+
/** `true` = defaults (intensity 0.2), an object = its knobs, `false` = off. */
|
|
171
|
+
bloom?: boolean | BloomOptions
|
|
172
|
+
/** `true` = defaults (a soft black edge), an object = its knobs, `false` = off. */
|
|
173
|
+
vignette?: boolean | VignetteOptions
|
|
174
|
+
/** `true` = defaults (radius 0.3 m), an object = its knobs, `false` = off. */
|
|
175
|
+
ambientOcclusion?: boolean | AmbientOcclusionOptions
|
|
176
|
+
/** `true` = defaults, an object = its knobs, `false` = off. */
|
|
177
|
+
screenSpaceReflections?: boolean | ScreenSpaceReflectionsOptions
|
|
178
|
+
/** `true` = focus at 10 m as f/2.8, an object = its knobs, `false` = off. */
|
|
179
|
+
depthOfField?: boolean | DepthOfFieldOptions
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The float layout of `_creator.setColorGrading` — creator.h's ColorGradingParam. Indices never
|
|
183
|
+
* move; a new field goes at the end and COUNT grows. */
|
|
184
|
+
export const CG = {
|
|
185
|
+
TONE_MAPPING: 0, EXPOSURE: 1, TEMPERATURE: 2, TINT: 3, CONTRAST: 4, VIBRANCE: 5, SATURATION: 6,
|
|
186
|
+
SHADOWS: 7, MIDTONES: 10, HIGHLIGHTS: 13, RANGES: 16, SLOPE: 20, OFFSET: 23, POWER: 26,
|
|
187
|
+
SHADOW_GAMMA: 29, MID_POINT: 32, HIGHLIGHT_SCALE: 35, LUT: 38,
|
|
188
|
+
COUNT: 39,
|
|
189
|
+
} as const
|
|
190
|
+
|
|
191
|
+
const TONE_MAPPING_MODE: Record<ToneMappingOperator, number> = { aces: 0, neutral: 1, linear: 2, filmic: 3 }
|
|
192
|
+
const QUALITY = { low: 0, medium: 1, high: 2, ultra: 3 } as const
|
|
193
|
+
const NO_TEXTURE = 0xFFFFFFFF
|
|
194
|
+
|
|
195
|
+
const ONE: Rgb = [ 1, 1, 1 ]
|
|
196
|
+
const ZERO: Rgb = [ 0, 0, 0 ]
|
|
197
|
+
const rgb = (v: Rgb | undefined, d: Rgb): Rgb => v ? [ v[0], v[1], v[2] ] : d
|
|
198
|
+
const pair = (v: readonly [number, number] | undefined, d: readonly [number, number]): readonly [number, number] => v ? [ v[0], v[1] ] : d
|
|
199
|
+
|
|
200
|
+
/** The grading band, normalised: every field present. */
|
|
201
|
+
type Grading = {
|
|
202
|
+
toneMapping: ToneMappingOperator
|
|
203
|
+
exposure: number
|
|
204
|
+
temperature: number
|
|
205
|
+
tint: number
|
|
206
|
+
contrast: number
|
|
207
|
+
saturation: number
|
|
208
|
+
vibrance: number
|
|
209
|
+
shadows: Rgb
|
|
210
|
+
midtones: Rgb
|
|
211
|
+
highlights: Rgb
|
|
212
|
+
rangeShadows: readonly [number, number]
|
|
213
|
+
rangeHighlights: readonly [number, number]
|
|
214
|
+
slope: Rgb
|
|
215
|
+
offset: Rgb
|
|
216
|
+
power: Rgb
|
|
217
|
+
shadowGamma: Rgb
|
|
218
|
+
midPoint: Rgb
|
|
219
|
+
highlightScale: Rgb
|
|
220
|
+
lut: string | null
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const defaultGrading = (): Grading => ({
|
|
224
|
+
toneMapping: "aces", exposure: 0, temperature: 0, tint: 0, contrast: 1, saturation: 1, vibrance: 1,
|
|
225
|
+
shadows: ONE, midtones: ONE, highlights: ONE, rangeShadows: [ 0, 0.333 ], rangeHighlights: [ 0.55, 1 ],
|
|
226
|
+
slope: ONE, offset: ZERO, power: ONE, shadowGamma: ONE, midPoint: ONE, highlightScale: ONE, lut: null,
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
const BLOOM_DEFAULTS: Required<BloomOptions> = { intensity: 0.2, threshold: true, highlight: 1000, levels: 6, quality: "medium", lensFlare: false, dirt: null, dirtStrength: 0.2 }
|
|
230
|
+
const VIGNETTE_DEFAULTS: Required<VignetteOptions> = { midPoint: 0.5, roundness: 0.5, feather: 0.5, color: "#000000" }
|
|
231
|
+
const AO_DEFAULTS: Required<AmbientOcclusionOptions> = { intensity: 1, radius: 0.3, power: 1, quality: "medium" }
|
|
232
|
+
const SSR_DEFAULTS: Required<ScreenSpaceReflectionsOptions> = { maxDistance: 3, thickness: 0.1, stride: 2 }
|
|
233
|
+
const DOF_DEFAULTS: Required<DepthOfFieldOptions> = { focusDistance: 10, aperture: 2.8, strength: 1 }
|
|
234
|
+
|
|
235
|
+
/** A live feature's new state from a write: `false` = off, `true` = on (the current knobs, or the
|
|
236
|
+
* defaults when it was off), an object = on with THOSE knobs changed. */
|
|
237
|
+
const merge = <T extends object>(current: Required<T> | null, v: boolean | T, defaults: Required<T>): Required<T> | null =>
|
|
238
|
+
v === false ? null : { ...(current ?? defaults), ...(v === true ? {} : v) }
|
|
239
|
+
|
|
240
|
+
const q = (v: number): number => Math.round(v * 1000) / 1000
|
|
241
|
+
const equal = (a: Float32Array, b: Float32Array): boolean => { for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true }
|
|
242
|
+
let identityPacked: Float32Array | null = null
|
|
243
|
+
const isIdentity = (p: Float32Array): boolean => equal(p, identityPacked ??= new PostProcessing(0)._pack())
|
|
244
|
+
|
|
245
|
+
/** The scene's post-processing (`scene.postProcessing`). Read a field for the current value, write
|
|
246
|
+
* one to change it; `apply(options)` writes the named ones; `reset()` is the defaults. */
|
|
247
|
+
export class PostProcessing {
|
|
248
|
+
private _g: Grading = defaultGrading()
|
|
249
|
+
private _bloom: Required<BloomOptions> | null = null
|
|
250
|
+
private _vignette: Required<VignetteOptions> | null = null
|
|
251
|
+
private _ao: Required<AmbientOcclusionOptions> | null = null
|
|
252
|
+
private _ssr: Required<ScreenSpaceReflectionsOptions> | null = null
|
|
253
|
+
private _dof: Required<DepthOfFieldOptions> | null = null
|
|
254
|
+
private _dirty = false
|
|
255
|
+
private _lutDirty = false
|
|
256
|
+
private _sentLut: string | null = null
|
|
257
|
+
private _sent: Float32Array | null = null // the last array the engine got; a scene nobody graded sends none
|
|
258
|
+
|
|
259
|
+
private readonly _sceneId: number
|
|
260
|
+
/** @internal */
|
|
261
|
+
constructor(sceneId: number) { this._sceneId = sceneId }
|
|
262
|
+
|
|
263
|
+
/** Write the fields named in `options`, nothing else — at every level: `{ bloom: { intensity:
|
|
264
|
+
* 0.4 } }` changes one bloom knob, `{ whiteBalance: { tint: 0.1 } }` keeps the temperature.
|
|
265
|
+
* The grading band goes to the engine at once (one bake), not in a microtask. */
|
|
266
|
+
apply(options: PostProcessingOptions): this {
|
|
267
|
+
if (options.toneMapping !== undefined) this.toneMapping = options.toneMapping
|
|
268
|
+
if (options.exposure !== undefined) this.exposure = options.exposure
|
|
269
|
+
if (options.whiteBalance !== undefined) this.whiteBalance = options.whiteBalance
|
|
270
|
+
if (options.contrast !== undefined) this.contrast = options.contrast
|
|
271
|
+
if (options.saturation !== undefined) this.saturation = options.saturation
|
|
272
|
+
if (options.vibrance !== undefined) this.vibrance = options.vibrance
|
|
273
|
+
if (options.shadows !== undefined) this.shadows = options.shadows
|
|
274
|
+
if (options.midtones !== undefined) this.midtones = options.midtones
|
|
275
|
+
if (options.highlights !== undefined) this.highlights = options.highlights
|
|
276
|
+
if (options.ranges !== undefined) this.ranges = options.ranges
|
|
277
|
+
if (options.slope !== undefined) this.slope = options.slope
|
|
278
|
+
if (options.offset !== undefined) this.offset = options.offset
|
|
279
|
+
if (options.power !== undefined) this.power = options.power
|
|
280
|
+
if (options.curves !== undefined) this.curves = options.curves
|
|
281
|
+
if (options.lut !== undefined) this.lut = options.lut
|
|
282
|
+
this.flush()
|
|
283
|
+
if (options.bloom !== undefined) this.bloom = options.bloom
|
|
284
|
+
if (options.vignette !== undefined) this.vignette = options.vignette
|
|
285
|
+
if (options.ambientOcclusion !== undefined) this.ambientOcclusion = options.ambientOcclusion
|
|
286
|
+
if (options.screenSpaceReflections !== undefined) this.screenSpaceReflections = options.screenSpaceReflections
|
|
287
|
+
if (options.depthOfField !== undefined) this.depthOfField = options.depthOfField
|
|
288
|
+
return this
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Every field back to its default: the engine's own look, every live feature off. */
|
|
292
|
+
reset(): this {
|
|
293
|
+
const g = defaultGrading()
|
|
294
|
+
this._lutDirty = g.lut !== this._sentLut
|
|
295
|
+
this._g = g
|
|
296
|
+
this._dirty = true
|
|
297
|
+
this.flush()
|
|
298
|
+
if (this._bloom) this.bloom = false
|
|
299
|
+
if (this._vignette) this.vignette = false
|
|
300
|
+
if (this._ao) this.ambientOcclusion = false
|
|
301
|
+
if (this._ssr) this.screenSpaceReflections = false
|
|
302
|
+
if (this._dof) this.depthOfField = false
|
|
303
|
+
return this
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---- the grading band: coalesced, quantised, baked by the engine ----
|
|
307
|
+
|
|
308
|
+
get toneMapping(): ToneMappingOperator { return this._g.toneMapping }
|
|
309
|
+
set toneMapping(v: ToneMappingOperator) { this._g.toneMapping = v; this._touch() }
|
|
310
|
+
get exposure(): number { return this._g.exposure }
|
|
311
|
+
set exposure(v: number) { this._g.exposure = v; this._touch() }
|
|
312
|
+
get whiteBalance(): Required<WhiteBalance> { return { temperature: this._g.temperature, tint: this._g.tint } }
|
|
313
|
+
set whiteBalance(v: WhiteBalance) {
|
|
314
|
+
if (v.temperature !== undefined) this._g.temperature = v.temperature
|
|
315
|
+
if (v.tint !== undefined) this._g.tint = v.tint
|
|
316
|
+
this._touch()
|
|
317
|
+
}
|
|
318
|
+
get contrast(): number { return this._g.contrast }
|
|
319
|
+
set contrast(v: number) { this._g.contrast = v; this._touch() }
|
|
320
|
+
get saturation(): number { return this._g.saturation }
|
|
321
|
+
set saturation(v: number) { this._g.saturation = v; this._touch() }
|
|
322
|
+
get vibrance(): number { return this._g.vibrance }
|
|
323
|
+
set vibrance(v: number) { this._g.vibrance = v; this._touch() }
|
|
324
|
+
get shadows(): Rgb { return this._g.shadows }
|
|
325
|
+
set shadows(v: Rgb) { this._g.shadows = rgb(v, ONE); this._touch() }
|
|
326
|
+
get midtones(): Rgb { return this._g.midtones }
|
|
327
|
+
set midtones(v: Rgb) { this._g.midtones = rgb(v, ONE); this._touch() }
|
|
328
|
+
get highlights(): Rgb { return this._g.highlights }
|
|
329
|
+
set highlights(v: Rgb) { this._g.highlights = rgb(v, ONE); this._touch() }
|
|
330
|
+
get ranges(): Required<TonalRanges> { return { shadows: this._g.rangeShadows, highlights: this._g.rangeHighlights } }
|
|
331
|
+
set ranges(v: TonalRanges) {
|
|
332
|
+
this._g.rangeShadows = pair(v.shadows, this._g.rangeShadows)
|
|
333
|
+
this._g.rangeHighlights = pair(v.highlights, this._g.rangeHighlights)
|
|
334
|
+
this._touch()
|
|
335
|
+
}
|
|
336
|
+
get slope(): Rgb { return this._g.slope }
|
|
337
|
+
set slope(v: Rgb) { this._g.slope = rgb(v, ONE); this._touch() }
|
|
338
|
+
get offset(): Rgb { return this._g.offset }
|
|
339
|
+
set offset(v: Rgb) { this._g.offset = rgb(v, ZERO); this._touch() }
|
|
340
|
+
get power(): Rgb { return this._g.power }
|
|
341
|
+
set power(v: Rgb) { this._g.power = rgb(v, ONE); this._touch() }
|
|
342
|
+
get curves(): Required<ColorCurves> { return { shadowGamma: this._g.shadowGamma, midPoint: this._g.midPoint, highlightScale: this._g.highlightScale } }
|
|
343
|
+
set curves(v: ColorCurves) {
|
|
344
|
+
this._g.shadowGamma = rgb(v.shadowGamma, this._g.shadowGamma)
|
|
345
|
+
this._g.midPoint = rgb(v.midPoint, this._g.midPoint)
|
|
346
|
+
this._g.highlightScale = rgb(v.highlightScale, this._g.highlightScale)
|
|
347
|
+
this._touch()
|
|
348
|
+
}
|
|
349
|
+
get lut(): string | null { return this._g.lut }
|
|
350
|
+
set lut(v: string | null) { this._g.lut = v; this._lutDirty = v !== this._sentLut; this._touch() }
|
|
351
|
+
|
|
352
|
+
// ---- the live band: view options, written at once ----
|
|
353
|
+
|
|
354
|
+
get bloom(): Required<BloomOptions> | false { return this._bloom ?? false }
|
|
355
|
+
set bloom(v: boolean | BloomOptions) {
|
|
356
|
+
const b = this._bloom = merge(this._bloom, v, BLOOM_DEFAULTS)
|
|
357
|
+
if (b) {
|
|
358
|
+
_creator.setBloomOptions(this._sceneId, true, b.intensity, b.threshold, b.highlight, b.levels,
|
|
359
|
+
QUALITY[b.quality], b.lensFlare, b.dirt ? b.dirt._h.id : NO_TEXTURE, b.dirtStrength)
|
|
360
|
+
} else {
|
|
361
|
+
_creator.setBloomOptions(this._sceneId, false, 0, true, 1000, 6, 1, false, NO_TEXTURE, 0)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
get vignette(): Required<VignetteOptions> | false { return this._vignette ?? false }
|
|
366
|
+
set vignette(v: boolean | VignetteOptions) {
|
|
367
|
+
const g = this._vignette = merge(this._vignette, v, VIGNETTE_DEFAULTS)
|
|
368
|
+
if (g) _creator.setVignetteOptions?.(this._sceneId, true, g.midPoint, g.roundness, g.feather, Color.toPackedRgba(g.color))
|
|
369
|
+
else _creator.setVignetteOptions?.(this._sceneId, false, 0.5, 0.5, 0.5, 0x000000FF)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
get ambientOcclusion(): Required<AmbientOcclusionOptions> | false { return this._ao ?? false }
|
|
373
|
+
set ambientOcclusion(v: boolean | AmbientOcclusionOptions) {
|
|
374
|
+
const o = this._ao = merge(this._ao, v, AO_DEFAULTS)
|
|
375
|
+
if (o) _creator.setAmbientOcclusionOptions?.(this._sceneId, true, o.intensity, o.radius, o.power, QUALITY[o.quality])
|
|
376
|
+
else _creator.setAmbientOcclusionOptions?.(this._sceneId, false, 1, 0.3, 1, 1)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
get screenSpaceReflections(): Required<ScreenSpaceReflectionsOptions> | false { return this._ssr ?? false }
|
|
380
|
+
set screenSpaceReflections(v: boolean | ScreenSpaceReflectionsOptions) {
|
|
381
|
+
const o = this._ssr = merge(this._ssr, v, SSR_DEFAULTS)
|
|
382
|
+
if (o) _creator.setScreenSpaceReflections?.(this._sceneId, true, o.maxDistance, o.thickness, o.stride)
|
|
383
|
+
else _creator.setScreenSpaceReflections?.(this._sceneId, false, 3, 0.1, 2)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
get depthOfField(): Required<DepthOfFieldOptions> | false { return this._dof ?? false }
|
|
387
|
+
set depthOfField(v: boolean | DepthOfFieldOptions) {
|
|
388
|
+
const o = this._dof = merge(this._dof, v, DOF_DEFAULTS)
|
|
389
|
+
if (o) _creator.setDepthOfField?.(this._sceneId, true, o.focusDistance, o.aperture, o.strength)
|
|
390
|
+
else _creator.setDepthOfField?.(this._sceneId, false, 0, 0, 1)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---- the flush ----
|
|
394
|
+
|
|
395
|
+
private _touch(): void {
|
|
396
|
+
if (this._dirty) return
|
|
397
|
+
this._dirty = true
|
|
398
|
+
Promise.resolve().then(() => this.flush())
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Send the grading band now (it is sent on its own at the end of the current handler). */
|
|
402
|
+
flush(): void {
|
|
403
|
+
if (!this._dirty) return
|
|
404
|
+
this._dirty = false
|
|
405
|
+
const g = this._g
|
|
406
|
+
if (this._lutDirty) {
|
|
407
|
+
// The LUT goes first, under the OLD flag, so the engine stores it and bakes ONCE on the
|
|
408
|
+
// parameter write that follows (creator.h setColorLut). Removal is the flag alone.
|
|
409
|
+
this._lutDirty = false
|
|
410
|
+
this._sentLut = g.lut
|
|
411
|
+
if (g.lut !== null) {
|
|
412
|
+
// `asset()` hands back "id:<n>" (already staged); a bare name is resolved on the spot.
|
|
413
|
+
const id = g.lut.startsWith("id:") ? Number(g.lut.slice(3)) : _creatorFetch.local(g.lut)
|
|
414
|
+
_creator.setColorLut?.(this._sceneId, id)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const packed = this._pack()
|
|
418
|
+
if (this._sent === null && isIdentity(packed)) return // never graded and still the default: nothing to say
|
|
419
|
+
if (this._sent !== null && equal(this._sent, packed)) return
|
|
420
|
+
this._sent = packed
|
|
421
|
+
_creator.setColorGrading?.(this._sceneId, packed)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** @internal The wire form of the grading band (tests). */
|
|
425
|
+
_pack(): Float32Array {
|
|
426
|
+
const g = this._g
|
|
427
|
+
const p = new Float32Array(CG.COUNT)
|
|
428
|
+
const put3 = (at: number, v: Rgb) => { p[at] = q(v[0]); p[at + 1] = q(v[1]); p[at + 2] = q(v[2]) }
|
|
429
|
+
p[CG.TONE_MAPPING] = TONE_MAPPING_MODE[g.toneMapping] ?? 0
|
|
430
|
+
p[CG.EXPOSURE] = q(g.exposure)
|
|
431
|
+
p[CG.TEMPERATURE] = q(g.temperature)
|
|
432
|
+
p[CG.TINT] = q(g.tint)
|
|
433
|
+
p[CG.CONTRAST] = q(g.contrast)
|
|
434
|
+
p[CG.VIBRANCE] = q(g.vibrance)
|
|
435
|
+
p[CG.SATURATION] = q(g.saturation)
|
|
436
|
+
put3(CG.SHADOWS, g.shadows)
|
|
437
|
+
put3(CG.MIDTONES, g.midtones)
|
|
438
|
+
put3(CG.HIGHLIGHTS, g.highlights)
|
|
439
|
+
p[CG.RANGES] = q(g.rangeShadows[0]); p[CG.RANGES + 1] = q(g.rangeShadows[1])
|
|
440
|
+
p[CG.RANGES + 2] = q(g.rangeHighlights[0]); p[CG.RANGES + 3] = q(g.rangeHighlights[1])
|
|
441
|
+
put3(CG.SLOPE, g.slope)
|
|
442
|
+
put3(CG.OFFSET, g.offset)
|
|
443
|
+
put3(CG.POWER, g.power)
|
|
444
|
+
put3(CG.SHADOW_GAMMA, g.shadowGamma)
|
|
445
|
+
put3(CG.MID_POINT, g.midPoint)
|
|
446
|
+
put3(CG.HIGHLIGHT_SCALE, g.highlightScale)
|
|
447
|
+
p[CG.LUT] = g.lut !== null ? 1 : 0
|
|
448
|
+
return p
|
|
449
|
+
}
|
|
450
|
+
}
|
package/src/runtime/app.ts
CHANGED
|
@@ -27,11 +27,50 @@ type AppEventMap = {
|
|
|
27
27
|
keyboard: (height: number, duration: number) => void
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** The installed app this bundle runs in — see `app.host`. */
|
|
31
|
+
export interface AppHost {
|
|
32
|
+
/** The store build's version string (Android versionName, iOS CFBundleShortVersionString; app.json
|
|
33
|
+
* `version` in a `lecodes app` shell). `undefined` where the host is not an installed app. */
|
|
34
|
+
version?: string
|
|
35
|
+
/** The store build's build number (versionCode / CFBundleVersion; app.json `buildNumber` /
|
|
36
|
+
* `versionCode`). `undefined` where the host is not an installed app, `0` when not numeric. */
|
|
37
|
+
build?: number
|
|
38
|
+
/** The SDK embedded in the host — the lecodes-ios-sdk / lecodes-android-sdk release a shell pins,
|
|
39
|
+
* the runtime's version elsewhere. */
|
|
40
|
+
sdk: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The bundle that is running — see `app.bundle`. */
|
|
44
|
+
export interface AppBundle {
|
|
45
|
+
/** The SDK this bundle was compiled with (`app.sdkVersion`). */
|
|
46
|
+
sdk: string
|
|
47
|
+
/** When it was compiled (ISO 8601) — the `// last-updated:` header line; `undefined` on a bundle
|
|
48
|
+
* without it. */
|
|
49
|
+
updatedAt?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
30
52
|
export const app = {
|
|
31
53
|
/** The SDK this app was compiled with (semver, e.g. `"2.0.0"`). Its major is the bundle ↔ runtime
|
|
32
54
|
* contract: a host runs only bundles of its own major, and the launchers send it as `?sdk=` when
|
|
33
|
-
* they fetch a published bundle — the platform keeps one bundle per major. */
|
|
55
|
+
* they fetch a published bundle — the platform keeps one bundle per major. Also `app.bundle.sdk`. */
|
|
34
56
|
sdkVersion: SDK_VERSION,
|
|
57
|
+
/** The INSTALLED app: the store build this bundle runs in. After an over-the-air update the code
|
|
58
|
+
* is newer than the binary, so a feature that needs native support added in a later store build
|
|
59
|
+
* (a plugin, an SDK method) branches on `app.host.sdk` / `app.host.build` — and on `typeof` of
|
|
60
|
+
* the method itself, which is the final word. Minor and patch of the SDK never gate anything. */
|
|
61
|
+
get host(): AppHost {
|
|
62
|
+
return {
|
|
63
|
+
version: _creatorApp.appVersion?.(),
|
|
64
|
+
build: _creatorApp.appBuild?.(),
|
|
65
|
+
sdk: _creatorApp.runtimeVersion?.() ?? SDK_VERSION,
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
/** The RUNNING code: what the compiler stamped into this bundle's header. `updatedAt` is the
|
|
69
|
+
* compile time — what the updater compares before replacing a bundle (an older one never
|
|
70
|
+
* replaces a newer one), useful for an "about" screen or a support log. */
|
|
71
|
+
get bundle(): AppBundle {
|
|
72
|
+
return { sdk: SDK_VERSION, updatedAt: _creatorApp.bundleUpdatedAt?.() ?? undefined }
|
|
73
|
+
},
|
|
35
74
|
/** Current lifecycle state. `"background"` while the app is not the foreground app / the tab is
|
|
36
75
|
* hidden. `"active"` on hosts that don't track it. */
|
|
37
76
|
get state(): AppState {
|
|
@@ -339,6 +339,7 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
|
|
|
339
339
|
Object.defineProperties(db, {
|
|
340
340
|
$schema: { get: () => seal().schema },
|
|
341
341
|
$models: { get: () => seal().models },
|
|
342
|
+
$given: { get: () => own },
|
|
342
343
|
$auth: { get: () => seal().auth },
|
|
343
344
|
$files: { get: () => seal().files },
|
|
344
345
|
})
|
package/src/server/db/types.ts
CHANGED
|
@@ -203,7 +203,11 @@ type DbBase<S extends Schema> = {
|
|
|
203
203
|
$transaction<P extends readonly Op<any>[]>(ops: [...P]): Promise<{ [K in keyof P]: P[K] extends Op<infer T> ? T : never }>
|
|
204
204
|
/** The `.marci` schema text this db syncs with. */
|
|
205
205
|
readonly $schema: string
|
|
206
|
-
/** @internal
|
|
206
|
+
/** @internal the models as the schema has them — a model with a `t.file()` field carries the files' relation. */
|
|
207
|
+
readonly $models: Record<string, Model>
|
|
208
|
+
/** @internal the models as `defineDb` received them, by their names here — the objects the project holds,
|
|
209
|
+
* so a host can tell which name the project gave a model it knows by identity. */
|
|
210
|
+
readonly $given: Record<string, Model>
|
|
207
211
|
/** @internal the collections sign-in works on; null = a db without `withAuth`. */
|
|
208
212
|
readonly $auth: AuthBinding | null
|
|
209
213
|
/** @internal the schema's `t.file()` fields (../files/models.ts). */
|
package/src/server/runtime.ts
CHANGED
|
@@ -40,9 +40,12 @@ export const loadServerModules = (
|
|
|
40
40
|
if (!reg) throw new Error("loadServerModules: no server bundle registered (globalThis.__lecodesServer)")
|
|
41
41
|
const out: LoadedServer = { endpoints: new Map(), channels: new Map(), dbs: [], manifest: reg.manifest }
|
|
42
42
|
for (const [mod, exports] of Object.entries(reg.modules)) {
|
|
43
|
+
// the compile's word on each export (serverSplit.ts): a function that is not async is the
|
|
44
|
+
// server's helper, never an endpoint — an old manifest without kinds counts every function
|
|
45
|
+
const kinds = new Map((reg.manifest?.modules?.[mod] ?? []).map(e => [e.name, e.kind]))
|
|
43
46
|
for (const [name, value] of Object.entries(exports)) {
|
|
44
47
|
const id = `${mod}#${name}`
|
|
45
|
-
if (typeof value === "function") out.endpoints.set(id, value as (...args: any[]) => unknown)
|
|
48
|
+
if (typeof value === "function") { if (kinds.get(name) !== "other") out.endpoints.set(id, value as (...args: any[]) => unknown) }
|
|
46
49
|
else if (isChannel(value)) { value.__id = id; out.channels.set(id, value) }
|
|
47
50
|
else if (isDb(value)) out.dbs.push(value)
|
|
48
51
|
}
|
package/src/version.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// same manifest). Its MAJOR is the bundle ↔ runtime contract: a bundle's `// sdk:` header line
|
|
5
5
|
// and a host's embedded version must agree on the major for the host to run the bundle; minor
|
|
6
6
|
// and patch never gate anything (features are detected, never versioned).
|
|
7
|
-
export const SDK_VERSION = "2.0
|
|
7
|
+
export const SDK_VERSION = "2.1.0"
|
|
8
8
|
|
|
9
9
|
/** The major of a semver string, or null when it is not one. */
|
|
10
10
|
export const sdkMajor = (version: string | null | undefined): number | null => {
|
|
@@ -15,13 +15,22 @@ export const storageStub: any = {}
|
|
|
15
15
|
export const mediaStub: any = {}
|
|
16
16
|
export const inputStub: any = {}
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
// Installed as accessors whose setter THROWS: a file that assigns the global (instead of mutating
|
|
19
|
+
// the stub) fails on its own line, not fifteen files later in whoever reads `_creatorFetch.fetch`
|
|
20
|
+
// next (the symptom of 2026-10-10: a replaced `_creatorFetch` broke rpc, channel, image and scene
|
|
21
|
+
// tests under every other seed).
|
|
22
|
+
const tables: [string, any][] = [
|
|
23
|
+
["_creatorApp", appStub], ["_creatorDevice", deviceStub], ["_creatorFetch", fetchStub],
|
|
24
|
+
["_creatorStorage", storageStub], ["_creatorMedia", mediaStub], ["_creatorInput", inputStub],
|
|
25
|
+
]
|
|
26
|
+
for (const [name, stub] of tables) {
|
|
27
|
+
Object.defineProperty(globalThis, name, {
|
|
28
|
+
get: () => stub,
|
|
29
|
+
set: () => { throw new Error(`${name} is a shared stub: mutate it (Object.assign / set a member, resetHostStubs()), never replace the global — tests/helpers/hostStubs.ts`) },
|
|
30
|
+
configurable: false,
|
|
31
|
+
enumerable: true,
|
|
32
|
+
})
|
|
33
|
+
}
|
|
25
34
|
|
|
26
35
|
/** Empty every stub table (the members a previous test / file installed). */
|
|
27
36
|
export const resetHostStubs = () => {
|