lecodes-cli 0.17.2 → 0.18.1
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 +1 -1
- package/dist/index.js +2376 -755
- package/package.json +4 -4
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/compile/aspectMacro.ts +52 -8
- package/runtime/sdk/compile/assetMacro.ts +116 -15
- package/runtime/sdk/compile/bundler.ts +39 -4
- package/runtime/sdk/compile/compileProject.ts +16 -1
- package/runtime/sdk/compile/header.ts +6 -1
- package/runtime/sdk/compile/index.ts +31 -0
- package/runtime/sdk/compile/liteMaterial.ts +247 -0
- package/runtime/sdk/compile/sceneEditor.ts +11 -1
- package/runtime/sdk/compile/shaderSchema.ts +202 -0
- package/runtime/sdk/compile/shaderTargets.ts +81 -0
- package/runtime/sdk/core/Aspect.ts +363 -95
- package/runtime/sdk/core/compWrite.ts +42 -0
- package/runtime/sdk/core/fields.ts +1 -1
- package/runtime/sdk/core/time.ts +81 -0
- package/runtime/sdk/g2/Camera2D.ts +8 -1
- package/runtime/sdk/g2/CharacterController2D.ts +253 -53
- package/runtime/sdk/g2/Node2D.ts +80 -10
- package/runtime/sdk/g2/OneWay2D.ts +66 -0
- package/runtime/sdk/g2/Physics2D.ts +240 -30
- package/runtime/sdk/g2/Scene2D.ts +33 -1
- package/runtime/sdk/g2/Shape2D.ts +218 -22
- package/runtime/sdk/g2/Trigger2D.ts +42 -12
- package/runtime/sdk/g2/groups2d.ts +106 -0
- package/runtime/sdk/g2/loop.ts +15 -4
- package/runtime/sdk/gl/Camera.ts +41 -0
- package/runtime/sdk/gl/CameraPlace.ts +52 -0
- package/runtime/sdk/gl/CharacterController.ts +184 -55
- package/runtime/sdk/gl/Gearbox.ts +212 -0
- package/runtime/sdk/gl/Geometry.ts +70 -9
- package/runtime/sdk/gl/IK.ts +193 -174
- package/runtime/sdk/gl/Light.ts +64 -2
- package/runtime/sdk/gl/Lightmap.ts +179 -0
- package/runtime/sdk/gl/Material.ts +36 -0
- package/runtime/sdk/gl/Mesh.ts +6 -23
- package/runtime/sdk/gl/Model.ts +23 -8
- package/runtime/sdk/gl/Node.ts +350 -285
- package/runtime/sdk/gl/Physics.ts +222 -126
- package/runtime/sdk/gl/Scene.ts +175 -8
- package/runtime/sdk/gl/Shape.ts +255 -12
- package/runtime/sdk/gl/Trigger.ts +1 -6
- package/runtime/sdk/gl/Vehicle.ts +473 -0
- package/runtime/sdk/gl/Wheel.ts +240 -0
- package/runtime/sdk/gl/{AnimationClip.ts → animation/AnimationClip.ts} +37 -7
- package/runtime/sdk/gl/animation/Animator.ts +87 -0
- package/runtime/sdk/gl/animation/Layer.ts +29 -0
- package/runtime/sdk/gl/animation/Loop.ts +25 -0
- package/runtime/sdk/gl/animation/Playback.ts +43 -0
- package/runtime/sdk/gl/animation/core.ts +294 -0
- package/runtime/sdk/gl/scenarios.ts +291 -349
- package/runtime/sdk/inject.ts +186 -162
- package/runtime/sdk/runtime/app.ts +13 -0
- package/runtime/sdk/runtime/input.ts +169 -6
- package/runtime/sdk/scene/defineScene.ts +1227 -1016
- package/runtime/sdk/scene/gizmos.ts +148 -0
- package/runtime/sdk/scene/material.ts +188 -0
- package/runtime/sdk-types.json +1 -1
- package/runtime/sdk/gl/Animator.ts +0 -642
- package/runtime/sdk/gl/ModelAnimation.ts +0 -95
|
@@ -28,6 +28,37 @@ export {
|
|
|
28
28
|
type ScanModule,
|
|
29
29
|
} from "./detectEntry"
|
|
30
30
|
export { sanitizeAssetName, isCanonicalAssetName } from "./assetName"
|
|
31
|
+
export {
|
|
32
|
+
PLATFORM_BUILD,
|
|
33
|
+
LOCAL_PLATFORM_BUILD,
|
|
34
|
+
ALL_PLATFORM_BUILD,
|
|
35
|
+
LOCAL_ONLY_PLATFORMS,
|
|
36
|
+
TARGET_MATRIX_GENERATION,
|
|
37
|
+
externalSamplerReg,
|
|
38
|
+
matcPlatformsFor,
|
|
39
|
+
matcArgs,
|
|
40
|
+
type MatcPlatform,
|
|
41
|
+
type LocalMatcPlatform,
|
|
42
|
+
type AnyMatcPlatform,
|
|
43
|
+
type ShaderPlatform,
|
|
44
|
+
type AnyShaderPlatform,
|
|
45
|
+
} from "./shaderTargets"
|
|
46
|
+
export {
|
|
47
|
+
buildLiteMaterialArtifact,
|
|
48
|
+
extractMatBlocks,
|
|
49
|
+
parseJsonish,
|
|
50
|
+
type LiteMaterialArtifact,
|
|
51
|
+
type LiteMaterialParam,
|
|
52
|
+
type JsonishValue,
|
|
53
|
+
} from "./liteMaterial"
|
|
54
|
+
export {
|
|
55
|
+
parseShaderSchema,
|
|
56
|
+
shaderDefaults,
|
|
57
|
+
type ShaderSchema,
|
|
58
|
+
type ShaderParam,
|
|
59
|
+
type ShaderParamEditor,
|
|
60
|
+
type ShaderParamValue,
|
|
61
|
+
} from "./shaderSchema"
|
|
31
62
|
export { offsetSourceMap, inlineSourceMapComment } from "./sourcemap"
|
|
32
63
|
export { buildHeader, detectProjectKind, type HeaderOptions } from "./header"
|
|
33
64
|
export {
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// .mat source → Web Lite material artifact.
|
|
2
|
+
//
|
|
3
|
+
// matc validates and compiles the .mat for the native platforms; the lite web runtime
|
|
4
|
+
// (packages/viewer-lite) has no Filament, so it gets this JSON artifact instead: the parsed
|
|
5
|
+
// material definition (parameters, shading model, blending) plus the author's fragment body with
|
|
6
|
+
// `materialParams.x` rewritten to `materialParams_x`. viewer-lite wraps that body in its own GLSL
|
|
7
|
+
// template (src/gl/customMaterial.ts mirrors the artifact type) — GLSL knowledge stays there, this
|
|
8
|
+
// module only owns .mat syntax.
|
|
9
|
+
//
|
|
10
|
+
// The parsed `params`/`samplers` lists are deliberately structured (filament type names kept
|
|
11
|
+
// verbatim): they are also the metadata a future "typed materials" codegen needs — generating a
|
|
12
|
+
// Material subclass with typed uniform setters straight from the shader's parameter list.
|
|
13
|
+
//
|
|
14
|
+
// This module is dependency-free and must never throw: an exotic material becomes an
|
|
15
|
+
// `unsupported` artifact (viewer-lite falls back to unlit + warning), never a failed shader save.
|
|
16
|
+
|
|
17
|
+
export type LiteMaterialParam = {
|
|
18
|
+
name: string
|
|
19
|
+
/** filament type name: float, float2..4, int..4, bool..4, float3x3, float4x4 */
|
|
20
|
+
type: string
|
|
21
|
+
/** array parameters ("float[9]") */
|
|
22
|
+
size?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type LiteMaterialArtifact = {
|
|
26
|
+
lecodesLiteMaterial: 1
|
|
27
|
+
shadingModel: "lit" | "unlit"
|
|
28
|
+
blending: "opaque" | "transparent" | "fade" | "add" | "masked"
|
|
29
|
+
maskThreshold: number
|
|
30
|
+
doubleSided: boolean
|
|
31
|
+
culling: "back" | "front" | "none"
|
|
32
|
+
depthWrite: boolean
|
|
33
|
+
/** Filament's shadow-catcher flag (unlit only, transparency mandatory — matc enforces both):
|
|
34
|
+
* the fragment shows baseColor scaled by the received shadow amount, transparent elsewhere. */
|
|
35
|
+
shadowMultiplier: boolean
|
|
36
|
+
params: LiteMaterialParam[]
|
|
37
|
+
/** sampler2d / samplerExternal parameters — all become sampler2D in lite */
|
|
38
|
+
samplers: { name: string }[]
|
|
39
|
+
/** the author's fragment block body, `materialParams.x` rewritten to `materialParams_x` */
|
|
40
|
+
fragmentCode: string
|
|
41
|
+
/** the fragment writes MaterialInputs.normal — the template adds the tangent-frame transform */
|
|
42
|
+
writesNormal: boolean
|
|
43
|
+
/** a vertex{} block was present and dropped (lite ignores custom vertex stages) */
|
|
44
|
+
hasVertexBlock: boolean
|
|
45
|
+
/** set when the material can't run in lite at all (parse failure, unsupported param type) */
|
|
46
|
+
unsupported?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- .mat block extraction ----------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** Split a .mat file into its top-level `name { ... }` blocks (material / fragment / vertex),
|
|
52
|
+
* matching braces while skipping comments so `{` inside GLSL or comments can't derail it. */
|
|
53
|
+
export const extractMatBlocks = (text: string): Record<string, string> => {
|
|
54
|
+
const blocks: Record<string, string> = {}
|
|
55
|
+
let i = 0
|
|
56
|
+
const skipNoise = (): void => {
|
|
57
|
+
for (;;) {
|
|
58
|
+
while (i < text.length && /\s/.test(text[i]!)) i++
|
|
59
|
+
if (text[i] === "/" && text[i + 1] === "/") { while (i < text.length && text[i] !== "\n") i++ }
|
|
60
|
+
else if (text[i] === "/" && text[i + 1] === "*") {
|
|
61
|
+
i += 2
|
|
62
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++
|
|
63
|
+
i += 2
|
|
64
|
+
} else return
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
while (i < text.length) {
|
|
68
|
+
skipNoise()
|
|
69
|
+
const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(text.slice(i))
|
|
70
|
+
if (!m) { i++; continue }
|
|
71
|
+
i += m[0].length
|
|
72
|
+
skipNoise()
|
|
73
|
+
if (text[i] !== "{") continue
|
|
74
|
+
// brace-match the block body, comment-aware
|
|
75
|
+
let depth = 0
|
|
76
|
+
const start = i
|
|
77
|
+
while (i < text.length) {
|
|
78
|
+
const c = text[i]
|
|
79
|
+
if (c === "/" && text[i + 1] === "/") { while (i < text.length && text[i] !== "\n") i++ }
|
|
80
|
+
else if (c === "/" && text[i + 1] === "*") {
|
|
81
|
+
i += 2
|
|
82
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++
|
|
83
|
+
i++ // lands on '/', loop increment steps past
|
|
84
|
+
}
|
|
85
|
+
else if (c === "{") depth++
|
|
86
|
+
else if (c === "}") { depth--; if (depth === 0) break }
|
|
87
|
+
i++
|
|
88
|
+
}
|
|
89
|
+
blocks[m[0]] = text.slice(start + 1, i)
|
|
90
|
+
i++
|
|
91
|
+
}
|
|
92
|
+
return blocks
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---- "jsonish" material-block parser --------------------------------------------------------------
|
|
96
|
+
// matc's material block is relaxed JSON: unquoted keys/values, comments, optional commas, and both
|
|
97
|
+
// `:` and `=` as separators. Small recursive-descent parser over that grammar.
|
|
98
|
+
|
|
99
|
+
export type JsonishValue = string | number | boolean | JsonishValue[] | { [key: string]: JsonishValue }
|
|
100
|
+
|
|
101
|
+
export const parseJsonish = (src: string): Record<string, JsonishValue> => {
|
|
102
|
+
let i = 0
|
|
103
|
+
const fail = (msg: string): never => { throw new Error(`material block: ${msg} at offset ${i}`) }
|
|
104
|
+
const skip = (): void => {
|
|
105
|
+
for (;;) {
|
|
106
|
+
while (i < src.length && /[\s,]/.test(src[i]!)) i++
|
|
107
|
+
if (src[i] === "/" && src[i + 1] === "/") { while (i < src.length && src[i] !== "\n") i++ }
|
|
108
|
+
else if (src[i] === "/" && src[i + 1] === "*") {
|
|
109
|
+
i += 2
|
|
110
|
+
while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) i++
|
|
111
|
+
i += 2
|
|
112
|
+
} else return
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const quoted = (q: string): string => {
|
|
116
|
+
i++
|
|
117
|
+
const start = i
|
|
118
|
+
while (i < src.length && src[i] !== q) i++
|
|
119
|
+
return src.slice(start, i++)
|
|
120
|
+
}
|
|
121
|
+
const word = (): string => {
|
|
122
|
+
const m = /^[^\s{}[\]:,="']+/.exec(src.slice(i))
|
|
123
|
+
if (!m) fail("expected a value")
|
|
124
|
+
i += m![0].length
|
|
125
|
+
return m![0]
|
|
126
|
+
}
|
|
127
|
+
const value = (): JsonishValue => {
|
|
128
|
+
skip()
|
|
129
|
+
const c = src[i]
|
|
130
|
+
if (c === "{") return object()
|
|
131
|
+
if (c === "[") return array()
|
|
132
|
+
if (c === '"' || c === "'") return quoted(c)
|
|
133
|
+
const w = word()
|
|
134
|
+
if (w === "true") return true
|
|
135
|
+
if (w === "false") return false
|
|
136
|
+
const n = Number(w)
|
|
137
|
+
return Number.isNaN(n) ? w : n
|
|
138
|
+
}
|
|
139
|
+
const array = (): JsonishValue[] => {
|
|
140
|
+
i++ // [
|
|
141
|
+
const out: JsonishValue[] = []
|
|
142
|
+
for (;;) {
|
|
143
|
+
skip()
|
|
144
|
+
if (i >= src.length) fail("unterminated array")
|
|
145
|
+
if (src[i] === "]") { i++; return out }
|
|
146
|
+
out.push(value())
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const object = (): Record<string, JsonishValue> => {
|
|
150
|
+
i++ // {
|
|
151
|
+
const out: Record<string, JsonishValue> = {}
|
|
152
|
+
for (;;) {
|
|
153
|
+
skip()
|
|
154
|
+
if (i >= src.length) fail("unterminated object")
|
|
155
|
+
if (src[i] === "}") { i++; return out }
|
|
156
|
+
const key = src[i] === '"' || src[i] === "'" ? quoted(src[i]!) : word()
|
|
157
|
+
skip()
|
|
158
|
+
if (src[i] !== ":" && src[i] !== "=") fail(`expected ':' after key "${key}"`)
|
|
159
|
+
i++
|
|
160
|
+
out[key] = value()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
skip()
|
|
164
|
+
if (src[i] !== "{") fail("expected '{'")
|
|
165
|
+
return object()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---- artifact builder -----------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
const PARAM_TYPES = new Set([
|
|
171
|
+
"float", "float2", "float3", "float4",
|
|
172
|
+
"int", "int2", "int3", "int4",
|
|
173
|
+
"bool", "bool2", "bool3", "bool4",
|
|
174
|
+
"float3x3", "float4x4",
|
|
175
|
+
])
|
|
176
|
+
const SAMPLER_TYPES = new Set(["sampler2d", "samplerExternal"])
|
|
177
|
+
|
|
178
|
+
const unsupported = (reason: string): LiteMaterialArtifact => ({
|
|
179
|
+
lecodesLiteMaterial: 1, unsupported: reason,
|
|
180
|
+
shadingModel: "unlit", blending: "opaque", maskThreshold: 0.4,
|
|
181
|
+
doubleSided: false, culling: "back", depthWrite: true, shadowMultiplier: false,
|
|
182
|
+
params: [], samplers: [], fragmentCode: "", writesNormal: false, hasVertexBlock: false,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
export const buildLiteMaterialArtifact = (text: string): LiteMaterialArtifact => {
|
|
186
|
+
try {
|
|
187
|
+
const blocks = extractMatBlocks(text)
|
|
188
|
+
if (blocks.material === undefined) return unsupported("no material block")
|
|
189
|
+
if (blocks.fragment === undefined) return unsupported("no fragment block")
|
|
190
|
+
const def = parseJsonish("{" + blocks.material + "}")
|
|
191
|
+
|
|
192
|
+
const params: LiteMaterialParam[] = []
|
|
193
|
+
const samplers: { name: string }[] = []
|
|
194
|
+
const rawParams = def.parameters === undefined ? []
|
|
195
|
+
: Array.isArray(def.parameters) ? def.parameters : [def.parameters]
|
|
196
|
+
for (const raw of rawParams) {
|
|
197
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return unsupported("malformed parameter entry")
|
|
198
|
+
const name = String((raw as Record<string, JsonishValue>).name ?? "")
|
|
199
|
+
const typeRaw = String((raw as Record<string, JsonishValue>).type ?? "")
|
|
200
|
+
const typeMatch = /^(\w+)(?:\[(\d+)\])?$/.exec(typeRaw)
|
|
201
|
+
if (!name || !typeMatch) return unsupported(`malformed parameter "${name || typeRaw}"`)
|
|
202
|
+
const [, type, size] = typeMatch
|
|
203
|
+
if (SAMPLER_TYPES.has(type!)) {
|
|
204
|
+
if (size) return unsupported(`sampler arrays are not supported ("${name}")`)
|
|
205
|
+
samplers.push({ name })
|
|
206
|
+
} else if (PARAM_TYPES.has(type!)) {
|
|
207
|
+
params.push(size ? { name, type: type!, size: Number(size) } : { name, type: type! })
|
|
208
|
+
} else {
|
|
209
|
+
return unsupported(`parameter type "${typeRaw}" ("${name}")`)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// cloth/subsurface/specularGlossiness approximate as lit — better than refusing outright.
|
|
214
|
+
const shadingModel = def.shadingModel === "unlit" ? "unlit" : "lit"
|
|
215
|
+
const blendingRaw = String(def.blending ?? "opaque")
|
|
216
|
+
const blending =
|
|
217
|
+
blendingRaw === "transparent" || blendingRaw === "fade" || blendingRaw === "add" || blendingRaw === "masked"
|
|
218
|
+
? blendingRaw
|
|
219
|
+
: blendingRaw === "multiply" || blendingRaw === "screen" ? "transparent" // approximate
|
|
220
|
+
: "opaque"
|
|
221
|
+
const culling = def.culling === "none" ? "none" : def.culling === "front" ? "front" : "back"
|
|
222
|
+
// Filament defaults: depth write on except for transparent/fade blending.
|
|
223
|
+
const depthWrite = typeof def.depthWrite === "boolean" ? def.depthWrite
|
|
224
|
+
: blending !== "transparent" && blending !== "fade" && blending !== "add"
|
|
225
|
+
|
|
226
|
+
const fragmentCode = blocks.fragment.replace(/\bmaterialParams\./g, "materialParams_")
|
|
227
|
+
if (!/\bvoid\s+material\s*\(/.test(fragmentCode)) return unsupported("fragment block has no material() function")
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
lecodesLiteMaterial: 1,
|
|
231
|
+
shadingModel,
|
|
232
|
+
blending,
|
|
233
|
+
maskThreshold: typeof def.maskThreshold === "number" ? def.maskThreshold : 0.4,
|
|
234
|
+
doubleSided: def.doubleSided === true,
|
|
235
|
+
culling,
|
|
236
|
+
depthWrite,
|
|
237
|
+
shadowMultiplier: def.shadowMultiplier === true,
|
|
238
|
+
params,
|
|
239
|
+
samplers,
|
|
240
|
+
fragmentCode,
|
|
241
|
+
writesNormal: /\.normal\s*=/.test(fragmentCode),
|
|
242
|
+
hasVertexBlock: blocks.vertex !== undefined,
|
|
243
|
+
}
|
|
244
|
+
} catch (e) {
|
|
245
|
+
return unsupported(e instanceof Error ? e.message : String(e))
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -66,13 +66,23 @@ export const sceneEditorEntries = (
|
|
|
66
66
|
.filter((p) => p.endsWith(".editor.ts"))
|
|
67
67
|
.map((p) => `import "${p}"\n`)
|
|
68
68
|
.join("")
|
|
69
|
+
// Every material asset registers on `__lecodesMaterials` BY PROJECT PATH, so the editor can
|
|
70
|
+
// resolve a scene's `material: handle` reference (an import the doc sees as an identifier) to
|
|
71
|
+
// the live handle and edit it in place. Imports dedupe with the scene's own, so a material the
|
|
72
|
+
// scene uses is the same instance here.
|
|
73
|
+
const materialPaths = projectPaths.filter((p) => p.endsWith(".material.ts"))
|
|
74
|
+
const materialImports = materialPaths.map((p, i) => `import __mat${i} from "${p}"\n`).join("")
|
|
75
|
+
const materialRegister = materialPaths.length === 0 ? ""
|
|
76
|
+
: `;(globalThis as any).__lecodesMaterials = { ${materialPaths.map((p, i) => `${JSON.stringify(p)}: __mat${i}`).join(", ")} }\n`
|
|
69
77
|
entries.push({
|
|
70
78
|
path: SCENE_ENTRY_PATH,
|
|
71
79
|
type: "text",
|
|
72
80
|
text: `import "${SCENE_HARNESS_DIR}/.edit-mode.ts"\n`
|
|
81
|
+
+ materialImports
|
|
73
82
|
+ `import "${scenePath}"\n`
|
|
74
83
|
+ editorImports
|
|
75
|
-
+ `import "${SCENE_HARNESS_DIR}/main.ts"\n
|
|
84
|
+
+ `import "${SCENE_HARNESS_DIR}/main.ts"\n`
|
|
85
|
+
+ materialRegister,
|
|
76
86
|
})
|
|
77
87
|
return entries
|
|
78
88
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// .mat source → the shader's PARAMETER SCHEMA: what the scene editor's material card renders and
|
|
2
|
+
// what `lecodes shaders` prints. Filament's material block carries name + type only, so the rest
|
|
3
|
+
// (widget kind, range, default, tooltip) comes from ANNOTATIONS in the comment that follows each
|
|
4
|
+
// parameter entry — the same trailing comments shader authors already write:
|
|
5
|
+
//
|
|
6
|
+
// parameters : [
|
|
7
|
+
// { type : float3, name : tint }, // @color what the coating does to the world behind it
|
|
8
|
+
// { type : float, name : opacity }, // @range(0, 1) @default(0.22) how much the glass darkens
|
|
9
|
+
// { type : sampler2d, name : mask }, // the reticle mask, white on alpha
|
|
10
|
+
// ]
|
|
11
|
+
//
|
|
12
|
+
// Tags: `@color` (float3/float4 = a colour picker; `@vec` forces the plain vector — without either,
|
|
13
|
+
// a float3/float4 whose NAME reads like a colour is one), `@range(min, max)` (slider), `@step(s)`,
|
|
14
|
+
// `@default(v)` (a number, `#hex`, `[x, y, z]`, `true`/`false`). Whatever text is left is the
|
|
15
|
+
// tooltip. A full-line comment directly above the entry counts too. Unknown tags are ignored; a
|
|
16
|
+
// malformed one lands in `errors` and the parameter still lists.
|
|
17
|
+
//
|
|
18
|
+
// Built on liteMaterial's block extraction + relaxed-JSON parser (the ONLY .mat syntax knowledge
|
|
19
|
+
// in the repo — do not fork it). Dependency-free, browser-safe, never throws.
|
|
20
|
+
|
|
21
|
+
import { extractMatBlocks, parseJsonish, type JsonishValue } from "./liteMaterial"
|
|
22
|
+
|
|
23
|
+
export type ShaderParamEditor =
|
|
24
|
+
| "number" | "int" | "switch" | "vec2" | "vec3" | "vec4" | "color" | "texture" | "matrix" | "array"
|
|
25
|
+
|
|
26
|
+
export type ShaderParamValue = number | boolean | string | number[] | null
|
|
27
|
+
|
|
28
|
+
export type ShaderParam = {
|
|
29
|
+
name: string
|
|
30
|
+
/** filament type name, verbatim (float, float3, sampler2d, …) */
|
|
31
|
+
type: string
|
|
32
|
+
/** array parameters ("float[9]") */
|
|
33
|
+
size?: number
|
|
34
|
+
editor: ShaderParamEditor
|
|
35
|
+
/** The value a new material starts with (from `@default`, else the type's zero — white for a colour). */
|
|
36
|
+
default: ShaderParamValue
|
|
37
|
+
min?: number
|
|
38
|
+
max?: number
|
|
39
|
+
step?: number
|
|
40
|
+
tooltip?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ShaderSchema = {
|
|
44
|
+
name: string
|
|
45
|
+
shadingModel: string
|
|
46
|
+
blending: string
|
|
47
|
+
params: ShaderParam[]
|
|
48
|
+
errors: string[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SAMPLER_TYPES = new Set([ "sampler2d", "samplerExternal", "sampler2dArray", "samplerCubemap", "sampler3d" ])
|
|
52
|
+
const COLOR_NAME = /colou?r|tint|albedo|emissive|glare|glow/i
|
|
53
|
+
|
|
54
|
+
const zeroFor = (type: string, editor: ShaderParamEditor): ShaderParamValue => {
|
|
55
|
+
switch (editor) {
|
|
56
|
+
case "number": return 0
|
|
57
|
+
case "int": return 0
|
|
58
|
+
case "switch": return false
|
|
59
|
+
case "vec2": return [ 0, 0 ]
|
|
60
|
+
case "vec3": return [ 0, 0, 0 ]
|
|
61
|
+
case "vec4": return [ 0, 0, 0, 0 ]
|
|
62
|
+
case "color": return type === "float4" ? "#ffffffff" : "#ffffff"
|
|
63
|
+
case "texture": return null
|
|
64
|
+
default: return null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Parse one `@default(...)` payload. */
|
|
69
|
+
const parseDefault = (raw: string, editor: ShaderParamEditor): ShaderParamValue | undefined => {
|
|
70
|
+
const s = raw.trim()
|
|
71
|
+
if (s === "true") return true
|
|
72
|
+
if (s === "false") return false
|
|
73
|
+
if (/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(s)) return s.toLowerCase()
|
|
74
|
+
if (/^-?\d+(\.\d+)?$/.test(s)) return editor === "switch" ? Number(s) !== 0 : Number(s)
|
|
75
|
+
const arr = /^\[(.*)\]$/.exec(s)
|
|
76
|
+
if (arr) {
|
|
77
|
+
const nums = arr[1]!.split(",").map((p) => Number(p.trim()))
|
|
78
|
+
if (nums.length > 0 && nums.every((n) => Number.isFinite(n))) return nums
|
|
79
|
+
}
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type Annotation = { color?: boolean, vec?: boolean, range?: [number, number], step?: number, default?: string, tooltip: string, errors: string[] }
|
|
84
|
+
|
|
85
|
+
/** Split a comment into tags + the remaining tooltip text. */
|
|
86
|
+
const parseAnnotation = (comment: string): Annotation => {
|
|
87
|
+
const out: Annotation = { tooltip: "", errors: [] }
|
|
88
|
+
let rest = comment
|
|
89
|
+
rest = rest.replace(/@(color|vec)\b/g, (_m, tag: string) => { out[tag as "color" | "vec"] = true; return " " })
|
|
90
|
+
rest = rest.replace(/@(range|step|default)\s*\(([^)]*)\)/g, (_m, tag: string, body: string) => {
|
|
91
|
+
if (tag === "range") {
|
|
92
|
+
const parts = body.split(",").map((p) => Number(p.trim()))
|
|
93
|
+
if (parts.length === 2 && parts.every((n) => Number.isFinite(n))) out.range = [ parts[0]!, parts[1]! ]
|
|
94
|
+
else out.errors.push(`@range expects two numbers, got "${body.trim()}"`)
|
|
95
|
+
} else if (tag === "step") {
|
|
96
|
+
const n = Number(body.trim())
|
|
97
|
+
if (Number.isFinite(n) && n > 0) out.step = n
|
|
98
|
+
else out.errors.push(`@step expects a positive number, got "${body.trim()}"`)
|
|
99
|
+
} else {
|
|
100
|
+
out.default = body
|
|
101
|
+
}
|
|
102
|
+
return " "
|
|
103
|
+
})
|
|
104
|
+
rest = rest.replace(/@\w+(\([^)]*\))?/g, " ") // unknown tags: dropped
|
|
105
|
+
out.tooltip = rest.replace(/\s+/g, " ").trim()
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The comment attached to each parameter entry, by parameter name: the trailing `//` on the
|
|
110
|
+
* entry's line, else the full-line comment(s) directly above it. Scans the raw block text
|
|
111
|
+
* (the relaxed-JSON parser drops comments). */
|
|
112
|
+
const collectComments = (materialBlock: string): Map<string, string> => {
|
|
113
|
+
const out = new Map<string, string>()
|
|
114
|
+
const start = materialBlock.search(/\bparameters\s*[:=]/)
|
|
115
|
+
if (start < 0) return out
|
|
116
|
+
const lines = materialBlock.slice(start).split("\n")
|
|
117
|
+
let above: string[] = []
|
|
118
|
+
for (const line of lines) {
|
|
119
|
+
const trimmed = line.trim()
|
|
120
|
+
const name = /\bname\s*[:=]\s*"?([A-Za-z_]\w*)"?/.exec(line)?.[1]
|
|
121
|
+
if (name) {
|
|
122
|
+
const trailing = /\}[^/\n]*\/\/(.*)$/.exec(line)?.[1] ?? /\/\/(.*)$/.exec(line.slice(line.indexOf("}") + 1))?.[1]
|
|
123
|
+
const text = trailing !== undefined ? trailing : above.join(" ")
|
|
124
|
+
if (text.trim() !== "") out.set(name, text.trim())
|
|
125
|
+
above = []
|
|
126
|
+
} else if (trimmed.startsWith("//")) {
|
|
127
|
+
above.push(trimmed.slice(2).trim())
|
|
128
|
+
} else if (trimmed !== "" && trimmed !== "[" && trimmed !== "],") {
|
|
129
|
+
above = []
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Parse a `.mat` source into its parameter schema. Never throws: an unreadable file yields an
|
|
136
|
+
* empty schema whose `errors` say why. */
|
|
137
|
+
export const parseShaderSchema = (text: string): ShaderSchema => {
|
|
138
|
+
const schema: ShaderSchema = { name: "", shadingModel: "lit", blending: "opaque", params: [], errors: [] }
|
|
139
|
+
let block: string | undefined
|
|
140
|
+
let def: Record<string, JsonishValue>
|
|
141
|
+
try {
|
|
142
|
+
block = extractMatBlocks(text).material
|
|
143
|
+
if (block === undefined) { schema.errors.push("no material block"); return schema }
|
|
144
|
+
def = parseJsonish("{" + block + "}")
|
|
145
|
+
} catch (e) {
|
|
146
|
+
schema.errors.push(e instanceof Error ? e.message : String(e))
|
|
147
|
+
return schema
|
|
148
|
+
}
|
|
149
|
+
schema.name = typeof def.name === "string" ? def.name : ""
|
|
150
|
+
schema.shadingModel = typeof def.shadingModel === "string" ? def.shadingModel : "lit"
|
|
151
|
+
schema.blending = typeof def.blending === "string" ? def.blending : "opaque"
|
|
152
|
+
|
|
153
|
+
const comments = collectComments(block)
|
|
154
|
+
const rawParams = def.parameters === undefined ? [] : Array.isArray(def.parameters) ? def.parameters : [ def.parameters ]
|
|
155
|
+
for (const raw of rawParams) {
|
|
156
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { schema.errors.push("malformed parameter entry"); continue }
|
|
157
|
+
const entry = raw as Record<string, JsonishValue>
|
|
158
|
+
const name = String(entry.name ?? "")
|
|
159
|
+
const typeRaw = String(entry.type ?? "")
|
|
160
|
+
const typeMatch = /^(\w+)(?:\[(\d+)\])?$/.exec(typeRaw)
|
|
161
|
+
if (!name || !typeMatch) { schema.errors.push(`malformed parameter "${name || typeRaw}"`); continue }
|
|
162
|
+
const type = typeMatch[1]!
|
|
163
|
+
const size = typeMatch[2] ? Number(typeMatch[2]) : undefined
|
|
164
|
+
const ann = parseAnnotation(comments.get(name) ?? "")
|
|
165
|
+
for (const e of ann.errors) schema.errors.push(`${name}: ${e}`)
|
|
166
|
+
|
|
167
|
+
let editor: ShaderParamEditor
|
|
168
|
+
if (size !== undefined) editor = "array"
|
|
169
|
+
else if (SAMPLER_TYPES.has(type)) editor = "texture"
|
|
170
|
+
else if (type === "float") editor = "number"
|
|
171
|
+
else if (type === "int" || type === "uint") editor = "int"
|
|
172
|
+
else if (type === "bool") editor = "switch"
|
|
173
|
+
else if (type === "float2") editor = "vec2"
|
|
174
|
+
else if (type === "float3" || type === "float4") {
|
|
175
|
+
const isColor = ann.color === true || (ann.vec !== true && COLOR_NAME.test(name))
|
|
176
|
+
editor = isColor ? "color" : type === "float3" ? "vec3" : "vec4"
|
|
177
|
+
} else if (/^(int|uint|bool)[234]$/.test(type)) editor = `vec${type.slice(-1)}` as ShaderParamEditor
|
|
178
|
+
else if (/^float[234]x[234]$/.test(type)) editor = "matrix"
|
|
179
|
+
else editor = "array"
|
|
180
|
+
|
|
181
|
+
const param: ShaderParam = { name, type, editor, default: zeroFor(type, editor) }
|
|
182
|
+
if (size !== undefined) param.size = size
|
|
183
|
+
if (ann.range) { param.min = ann.range[0]; param.max = ann.range[1] }
|
|
184
|
+
if (ann.step !== undefined) param.step = ann.step
|
|
185
|
+
if (ann.default !== undefined) {
|
|
186
|
+
const v = parseDefault(ann.default, editor)
|
|
187
|
+
if (v === undefined) schema.errors.push(`${name}: unreadable @default(${ann.default.trim()})`)
|
|
188
|
+
else param.default = v
|
|
189
|
+
}
|
|
190
|
+
if (ann.tooltip) param.tooltip = ann.tooltip
|
|
191
|
+
schema.params.push(param)
|
|
192
|
+
}
|
|
193
|
+
return schema
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The parameter values a NEW material starts with for this shader (the schema defaults, textures
|
|
197
|
+
* left out — an unset sampler stays the engine's white). */
|
|
198
|
+
export const shaderDefaults = (schema: ShaderSchema): Record<string, ShaderParamValue> => {
|
|
199
|
+
const out: Record<string, ShaderParamValue> = {}
|
|
200
|
+
for (const p of schema.params) if (p.editor !== "texture" && p.default !== null) out[p.name] = p.default
|
|
201
|
+
return out
|
|
202
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The one shader-compile target matrix, shared by the backend's server-side compile
|
|
2
|
+
// (backend/src/utils/compileShader.ts) and the CLI's local compile (lecodes-cli
|
|
3
|
+
// src/compile/shaders.ts). Both must produce the SAME artifact family for a .mat source —
|
|
4
|
+
// the compiled bundle derives per-backend URLs by substituting `_creator.subBackend ??
|
|
5
|
+
// _creator.backend` into the opengl src (compileProject.ts), so the set of names is ABI.
|
|
6
|
+
|
|
7
|
+
export const externalSamplerReg = /type\s*:\s*samplerExternal/g
|
|
8
|
+
|
|
9
|
+
// matc invocation per target. `profile` is matc's -p (mobile = shader model MOBILE / ESSL3,
|
|
10
|
+
// desktop = shader model DESKTOP / GLSL 4.1); `api` is -a. WebGL and the Windows desktop host
|
|
11
|
+
// both run OpenGL without external (OES) sampler support, so they get samplerExternal rewritten
|
|
12
|
+
// to sampler2d. The desktop variant exists because the desktop Filament engine rejects the mobile
|
|
13
|
+
// _opengl shader ("not built for desktop"); _creator.backend returns "desktop" there.
|
|
14
|
+
//
|
|
15
|
+
// These names are opaque identities for a (profile, api, rewrite) triple, not API names — hence
|
|
16
|
+
// "opengl" (mobile profile) and "desktop" (desktop profile) both being OpenGL. Their only job is to
|
|
17
|
+
// be stable and unique: the runtime substitutes one into `<hash>_<name>.filamat`.
|
|
18
|
+
export const PLATFORM_BUILD = {
|
|
19
|
+
opengl: { profile: "mobile", api: "opengl", replaceExternal: false },
|
|
20
|
+
metal: { profile: "mobile", api: "metal", replaceExternal: false },
|
|
21
|
+
webgl: { profile: "mobile", api: "opengl", replaceExternal: true },
|
|
22
|
+
desktop: { profile: "desktop", api: "opengl", replaceExternal: true },
|
|
23
|
+
} as const
|
|
24
|
+
export type MatcPlatform = keyof typeof PLATFORM_BUILD
|
|
25
|
+
|
|
26
|
+
// Targets the CLI compiles LOCALLY and the platform does not. Deliberately a separate table, not
|
|
27
|
+
// another PLATFORM_BUILD entry: `MatcPlatform` feeds the backend's `Platform` type, which is checked
|
|
28
|
+
// against the `shaderPlatform` pg enum — widening it would break the backend's inserts at compile
|
|
29
|
+
// time for a value the server never produces. Keep local-only targets here until the platform grows
|
|
30
|
+
// the matching enum value (docs/desktop-vulkan-release-plan.md §4 "Known limitation").
|
|
31
|
+
//
|
|
32
|
+
// 'desktop-vulkan' = the Windows Vulkan desktop host: same DESKTOP shader model as 'desktop' but
|
|
33
|
+
// SPIR-V, and the two are not interchangeable — Filament rejects a foreign one outright ("not built
|
|
34
|
+
// for any of the OpenGL backend's supported shader languages"). `_creator.backend` returns
|
|
35
|
+
// "desktop-vulkan" on that host.
|
|
36
|
+
export const LOCAL_PLATFORM_BUILD = {
|
|
37
|
+
"desktop-vulkan": { profile: "desktop", api: "vulkan", replaceExternal: true },
|
|
38
|
+
} as const
|
|
39
|
+
export type LocalMatcPlatform = keyof typeof LOCAL_PLATFORM_BUILD
|
|
40
|
+
|
|
41
|
+
/** Every matc target, local-only ones included — the lookup table for matcArgs. */
|
|
42
|
+
export const ALL_PLATFORM_BUILD = { ...PLATFORM_BUILD, ...LOCAL_PLATFORM_BUILD }
|
|
43
|
+
export type AnyMatcPlatform = MatcPlatform | LocalMatcPlatform
|
|
44
|
+
|
|
45
|
+
/** The local-only targets, as a list (order matters where it feeds an artifact family). */
|
|
46
|
+
export const LOCAL_ONLY_PLATFORMS: LocalMatcPlatform[] = ["desktop-vulkan"]
|
|
47
|
+
|
|
48
|
+
// 'webgl-lite' is not a matc target: viewer-lite gets a JSON artifact (parsed .mat — see
|
|
49
|
+
// liteMaterial.ts) that it wraps in its own GLSL template at runtime.
|
|
50
|
+
export type ShaderPlatform = MatcPlatform | "webgl-lite"
|
|
51
|
+
|
|
52
|
+
/** Every platform the LOCAL compile can produce (matc targets + the lite JSON). */
|
|
53
|
+
export type AnyShaderPlatform = AnyMatcPlatform | "webgl-lite"
|
|
54
|
+
|
|
55
|
+
/** Bump when the matc invocation for an EXISTING target name changes (a different -p/-a/rewrite for
|
|
56
|
+
* a name that already exists). The CLI folds this into its artifact cache key, so such a change
|
|
57
|
+
* invalidates caches that would otherwise look complete — adding or removing a target name is
|
|
58
|
+
* already caught by the artifact-family check, but changing the argv behind a name is not.
|
|
59
|
+
* 1 = 'desktop-vulkan' added (2026-08-24). */
|
|
60
|
+
export const TARGET_MATRIX_GENERATION = 1
|
|
61
|
+
|
|
62
|
+
/** The matc platforms a .mat source needs compiled. WebGL is matc-compiled only when the shader
|
|
63
|
+
* has an external sampler: its samplerExternal → sampler2d rewrite makes the output differ from
|
|
64
|
+
* the mobile opengl build. Without one the WebGL artifact is byte-identical to the mobile shader,
|
|
65
|
+
* so callers copy it from the opengl output instead of running matc again. Either way a _webgl
|
|
66
|
+
* artifact must always exist — the web runtime always requests one (via _creator.subBackend).
|
|
67
|
+
*
|
|
68
|
+
* This is the SERVER's contract — it deliberately does not include LOCAL_ONLY_PLATFORMS. The CLI
|
|
69
|
+
* appends those itself (lecodes-cli src/compile/shaders.ts). */
|
|
70
|
+
export const matcPlatformsFor = (text: string): MatcPlatform[] => {
|
|
71
|
+
externalSamplerReg.lastIndex = 0
|
|
72
|
+
return externalSamplerReg.test(text)
|
|
73
|
+
? ["opengl", "metal", "webgl", "desktop"]
|
|
74
|
+
: ["opengl", "metal", "desktop"]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The matc argv (without the executable) for one target. */
|
|
78
|
+
export const matcArgs = (platform: AnyMatcPlatform, outPath: string, inPath: string): string[] => {
|
|
79
|
+
const build = ALL_PLATFORM_BUILD[platform]
|
|
80
|
+
return ["-p", build.profile, "--no-essl1", "-a", build.api, "-o", outPath, inPath]
|
|
81
|
+
}
|