@aura3d/cli 5.0.1 → 5.0.3
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/package.json +6 -6
- package/skills/agent-docs/api-signatures.md +411 -0
- package/skills/agent-docs/recipes.md +735 -0
- package/skills/agent-docs/threejs-to-aura3d.md +30 -0
- package/skills/aura3d-art-direction/SKILL.md +4 -0
- package/skills/aura3d-core/SKILL.md +23 -30
- package/skills/aura3d-materials-environments/SKILL.md +2 -2
- package/skills/aura3d-scene-authoring/SKILL.md +7 -0
- package/skills/aura3d-threejs-migration/SKILL.md +5 -0
- package/skills/llms.txt +25 -16
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
# Recipes: copy a working scene, then adapt it
|
|
2
|
+
|
|
3
|
+
Coming from three.js? Read [`threejs-to-aura3d.md`](threejs-to-aura3d.md) first; signatures are in [`api-signatures.md`](api-signatures.md). Recipes 8 to 15 build complete scenes with no admitted models. Start here when the task looks like one of these. Each recipe is a complete `src/main.ts` body that compiled against the engine types (`tsc`, strict) and uses only non-deprecated root exports. Copy it, change names and numbers, run `npm run build`. Do not read `node_modules/@aura3d/engine/dist` to discover the API first: that is the slowest path (in the cold-start trial, 8 of 15 runs spent all 40 turns reading `.d.ts` and `dist` files without writing a scene, while the game-loop runs, which had a complete sample to copy, finished in 11 to 17 turns).
|
|
4
|
+
|
|
5
|
+
Page needs a `<div id="app">` (or a `<canvas id="app">`); `createAuraApp("#app", { scene })` mounts into it. Background, camera, lights and fog are ordinary nodes or scene options. Animate inside `app.onFrame((frame) => ...)` using `frame.dt`. Await `app.ready()` when you need the first frame. With a HUD or other elements beside the canvas, give the canvas a wrapper of definite size (`<div id="stage" style="width:640px;height:360px"><canvas id="app"></canvas></div>`), or pass `resize: false` and a `width`/`height` on the canvas; the app sizes itself to its parent, and an auto-height parent that also holds a HUD can make a canvas grow until it draws nothing on 4.1.3 and earlier (see troubleshooting, black frame checklist).
|
|
6
|
+
|
|
7
|
+
Rules that every recipe follows:
|
|
8
|
+
|
|
9
|
+
- Orbit control: `interactions.orbit({ target: "<node name>" })`. It takes a node name, nothing else.
|
|
10
|
+
- Metals need something to reflect: add `environments.neutral(...)` (or a look preset) or a metallic material renders dark.
|
|
11
|
+
- Instancing: `instances.cone/cylinder/sphere/box({ transforms, colors })` draws one draw call per shape; shapes are unit-sized, so `scale` sets the size.
|
|
12
|
+
- A deformed surface needs vertices: build a grid with `geometry.define` and mount it with `geometry.custom(...)`.
|
|
13
|
+
- Anything you move, rotate, or play at runtime needs `.runtime({ id, mutable: true })` on its node; `app.nodes.require(id)` throws without it. (If a callback in `onFrame` throws, 4.2.0 and later log `onFrame callback threw: ...` once and keep rendering; 4.1.3 and earlier stop drawing and leave a blank page.)
|
|
14
|
+
- Never silence a type error with `as never` or `as any`: the error is the real answer.
|
|
15
|
+
|
|
16
|
+
## 1. Rotating PBR cube with orbit control
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { camera, createAuraApp, environments, interactions, lights, material, primitives, scene } from "@aura3d/engine";
|
|
20
|
+
|
|
21
|
+
const app = createAuraApp("#app", {
|
|
22
|
+
scene: scene({ background: "#2b2b2e" })
|
|
23
|
+
.add(environments.neutral({ intensity: 1, background: false })) // metals need something to reflect
|
|
24
|
+
.add(lights.directional({ name: "key", position: [4, 5, 4], target: [0, 0, 0], intensity: 3, shadow: true }))
|
|
25
|
+
.add(lights.directional({ name: "fill", position: [-5, 2, 3], target: [0, 0, 0], intensity: 1.2, color: "#9db8ff" }))
|
|
26
|
+
.add(primitives.box({ name: "cube", material: material.pbr({ color: "#c8ccd4", metallic: 0.9, roughness: 0.38 }) }).runtime({ id: "cube", mutable: true })) // runtime + mutable: required to move it later
|
|
27
|
+
.add(interactions.orbit({ target: "cube" })) // target is a node NAME
|
|
28
|
+
.camera(camera.orbit({ position: [2.8, 1.9, 3.4], target: [0, 0, 0], fov: 40 }))
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
let angle = 0;
|
|
32
|
+
app.onFrame((frame) => {
|
|
33
|
+
angle += frame.dt * 0.8; // animate in onFrame, scaled by frame.dt
|
|
34
|
+
app.nodes.require("cube").setRotation(angle * 0.45, angle, 0);
|
|
35
|
+
});
|
|
36
|
+
await app.ready(); // renderer mounted; programs may still be compiling, so the first frames can miss draws (games: await warmPrograms(app) from @aura3d/engine/game)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 2. A forest of thousands of instances (fog, shadows, per-instance colour)
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { createAuraApp, effects, instances, lights, material, primitives, scene, type AuraTransformSpec } from "@aura3d/engine";
|
|
43
|
+
|
|
44
|
+
let seed = 12345; // any seeded RNG: same forest every load
|
|
45
|
+
const rand = (): number => ((seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 4294967296);
|
|
46
|
+
|
|
47
|
+
const trunks: AuraTransformSpec[] = [];
|
|
48
|
+
const canopies: AuraTransformSpec[] = [];
|
|
49
|
+
const canopyColors: string[] = [];
|
|
50
|
+
for (let i = 0; i < 8000; i += 1) {
|
|
51
|
+
const a = rand() * Math.PI * 2;
|
|
52
|
+
const r = 75 * Math.sqrt(rand());
|
|
53
|
+
const x = Math.cos(a) * r;
|
|
54
|
+
const z = Math.sin(a) * r;
|
|
55
|
+
const s = 0.8 + rand() * 0.8;
|
|
56
|
+
trunks.push({ position: [x, 0.8 * s, z], scale: [0.34 * s, 1.6 * s, 0.34 * s] }); // unit shapes: scale sets the size
|
|
57
|
+
canopies.push({ position: [x, 1.3 * s + 2, z], scale: [2.4 * s, 4 * s, 2.4 * s] });
|
|
58
|
+
canopyColors.push(`hsl(${100 + Math.floor(rand() * 40)}, 55%, ${25 + Math.floor(rand() * 15)}%)`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
createAuraApp("#app", {
|
|
62
|
+
scene: scene({ background: "#b4c8d8", camera: { mode: "perspective", position: [0, 80, 125], target: [0, 2, 0], fov: 45, near: 20, far: 450 } })
|
|
63
|
+
.add(lights.hemisphere({ skyColor: "#cfe0f2", groundColor: "#5d6b45", intensity: 1.1 }))
|
|
64
|
+
.add(lights.directional({ position: [90, 130, 60], target: [0, 0, 0], intensity: 3, shadow: true }))
|
|
65
|
+
.add(effects.fog({ color: "#b4c8d8", density: 0.0055 }))
|
|
66
|
+
.add(primitives.plane({ name: "ground", size: [260, 1, 260], material: material.pbr({ color: "#5e7a3e", roughness: 1 }) }))
|
|
67
|
+
.add(instances.cylinder({ name: "trunks", transforms: trunks, material: material.pbr({ color: "#5a3d24", roughness: 0.95 }), params: { radialSegments: 6 } }))
|
|
68
|
+
.add(instances.cone({ name: "canopies", transforms: canopies, colors: canopyColors, material: material.pbr({ color: "#ffffff", roughness: 0.9 }), params: { radialSegments: 8 } }))
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Add rocks as a third `instances.sphere` node. 10,000 instances is fine: three `instances.*` nodes are three draw calls.
|
|
73
|
+
|
|
74
|
+
## 3. A skinned character with clips and a crossfade
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { camera, createAuraApp, defineModelAssets, lights, material, primitives, scene } from "@aura3d/engine";
|
|
78
|
+
|
|
79
|
+
// With a shell, use `aura3d assets add ./soldier.glb --name soldier` (writes src/aura-assets.ts). Without one,
|
|
80
|
+
// declare the same shape by hand: url (same origin), sha256 of the file, animations, license.
|
|
81
|
+
const assets = defineModelAssets(
|
|
82
|
+
{ soldier: { url: "/assets/soldier.glb", sha256: "dfb230fc1f942f259dd00281a1186953ad602fc5d69067ce63e24b2aa439736b", animations: ["Idle", "Run", "TPose", "Walk"], license: "fixture" } },
|
|
83
|
+
{ queryParam: null }
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const app = createAuraApp("#app", {
|
|
87
|
+
scene: scene({ background: "#2a2e36" })
|
|
88
|
+
.add(lights.hemisphere({ skyColor: "#cfd8ea", groundColor: "#4a4640", intensity: 0.9 }))
|
|
89
|
+
.add(lights.directional({ position: [3, 6, 4], target: [0, 0.9, 0], intensity: 2.4, shadow: true }))
|
|
90
|
+
.camera(camera.perspective({ position: [0, 1.25, 4.6], target: [0, 0.9, 0], fov: 40 }))
|
|
91
|
+
.add(primitives.plane({ name: "ground", material: material.pbr({ color: "#7b7f86", roughness: 0.9 }), castShadow: false }).scale([12, 1, 12]))
|
|
92
|
+
.add(assets.node("soldier", { name: "soldier-model", targetHeight: 1.8 }).animate({ clip: "Idle", loop: true }).runtime({ id: "soldier" }))
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
window.addEventListener("keydown", (event) => {
|
|
96
|
+
if (event.code === "KeyR") app.nodes.require("soldier").play("Run", { loop: true, crossFade: 0.4 });
|
|
97
|
+
if (event.code === "KeyI") app.nodes.require("soldier").play("Idle", { loop: true, crossFade: 0.4 });
|
|
98
|
+
});
|
|
99
|
+
await app.ready();
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`.animate({ clip, loop })` sets the starting clip; `app.nodes.require(id).play(clip, { crossFade })` blends to another. Clip names come from the file (`aura3d assets inspect --animation` lists them).
|
|
103
|
+
|
|
104
|
+
## 4. A deterministic fixed-step game loop with restart
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { createFixedStepLoop } from "@aura3d/engine";
|
|
108
|
+
|
|
109
|
+
interface State { x: number; vx: number; score: number; over: boolean }
|
|
110
|
+
const state: State = { x: 0, vx: 2, score: 0, over: false };
|
|
111
|
+
const STEP = 1 / 60;
|
|
112
|
+
|
|
113
|
+
const loop = createFixedStepLoop({
|
|
114
|
+
stepSeconds: STEP,
|
|
115
|
+
step: (dt) => { // deterministic: no Date.now, no Math.random here
|
|
116
|
+
if (state.over) return;
|
|
117
|
+
state.x += state.vx * dt;
|
|
118
|
+
if (Math.abs(state.x) > 5) state.over = true;
|
|
119
|
+
else state.score += 1;
|
|
120
|
+
},
|
|
121
|
+
render: () => { /* copy state into scene nodes / DOM here */ }
|
|
122
|
+
});
|
|
123
|
+
loop.start();
|
|
124
|
+
|
|
125
|
+
window.addEventListener("keydown", (event) => {
|
|
126
|
+
if (event.code === "KeyR") { state.x = 0; state.score = 0; state.over = false; loop.resetAccumulator(); } // restart
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
For a complete game (HUD, touch, sound, pause, QA hooks, replay), follow [`build-a-game.md`](build-a-game.md) and its sample `docs/agents/samples/coin-dash/`.
|
|
131
|
+
|
|
132
|
+
## 5. Custom GLSL (animated water on a subdivided grid)
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import { createAuraApp, geometry, material, scene } from "@aura3d/engine";
|
|
136
|
+
|
|
137
|
+
function grid(n: number, size: number) { // subdivided grid: vertex displacement needs vertices
|
|
138
|
+
const positions = new Float32Array((n + 1) * (n + 1) * 3);
|
|
139
|
+
const normals = new Float32Array(positions.length);
|
|
140
|
+
const indices: number[] = [];
|
|
141
|
+
for (let j = 0; j <= n; j += 1) for (let i = 0; i <= n; i += 1) {
|
|
142
|
+
const k = (j * (n + 1) + i) * 3;
|
|
143
|
+
positions[k] = (i / n - 0.5) * size; positions[k + 2] = (j / n - 0.5) * size; normals[k + 1] = 1;
|
|
144
|
+
if (i < n && j < n) { const a = j * (n + 1) + i; indices.push(a, a + n + 1, a + 1, a + 1, a + n + 1, a + n + 2); }
|
|
145
|
+
}
|
|
146
|
+
return geometry.define({ positions, normals, indices, bounds: { min: [-size / 2, -2, -size / 2], max: [size / 2, 2, size / 2] } });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const water = material.custom({
|
|
150
|
+
glsl: {
|
|
151
|
+
vertex: /* glsl */ `
|
|
152
|
+
out vec3 vPos;
|
|
153
|
+
void main() {
|
|
154
|
+
vec3 p = position; // prelude names: position, normal, uv, modelViewProjectionMatrix, modelMatrix, cameraPosition, uTime (static scenes do not advance uTime; see below)
|
|
155
|
+
p.y += 0.25 * sin(p.x * 0.8 + waveClock) + 0.15 * sin(p.z * 1.3 - waveClock * 1.4);
|
|
156
|
+
vPos = p;
|
|
157
|
+
gl_Position = modelViewProjectionMatrix * vec4(p, 1.0);
|
|
158
|
+
}`,
|
|
159
|
+
fragment: /* glsl */ `
|
|
160
|
+
in vec3 vPos;
|
|
161
|
+
out vec4 outColor;
|
|
162
|
+
void main() {
|
|
163
|
+
float h = vPos.y * 1.6 + 0.5;
|
|
164
|
+
outColor = vec4(mix(vec3(0.02, 0.22, 0.42), vec3(0.45, 0.78, 0.9), clamp(h, 0.0, 1.0)), 1.0);
|
|
165
|
+
}`
|
|
166
|
+
},
|
|
167
|
+
uniforms: { waveClock: { type: "f32", value: 0 } } // declare uniforms here; they are auto-declared in GLSL
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const app = createAuraApp("#app", {
|
|
171
|
+
scene: scene({ background: "#a9c8e6", camera: { mode: "perspective", position: [0, 4, 10], target: [0, 0, 0], fov: 50 } })
|
|
172
|
+
.add(geometry.custom(grid(160, 30), { name: "water", material: water }).runtime({ id: "water", mutable: true }))
|
|
173
|
+
});
|
|
174
|
+
const start = performance.now();
|
|
175
|
+
app.onFrame(() => app.nodes.require("water").setUniform?.("waveClock", (performance.now() - start) / 1000)); // needs .runtime({ id, mutable: true })
|
|
176
|
+
await app.ready();
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`material.custom` auto-declares the uniforms you list. Prelude names: `position`, `normal` and `uv` (vertex attributes), `modelViewProjectionMatrix`, `modelMatrix`, `cameraPosition` (a uniform in both stages) and `uTime`. `uTime` is wall-clock seconds, but the engine redraws every frame only when the scene declares motion (a `.runtime({ id, mutable: true })` node, an `app.onFrame` callback, an animated model, particles or rain, an auto-rotating orbit camera, a looping timeline). A static scene whose only motion is `uTime` renders one frame and the shader looks frozen (measured on a real GPU). This recipe uses its own `waveClock` uniform written in `onFrame`, which also keeps the loop running; `uTime` works too when the node is `.runtime({ id, mutable: true })`. The fragment stage writes `outColor` (GLSL 3 `in`/`out`). There is no `eyePos`: it fails to compile with `undeclared identifier`. If the shader fails to compile the engine logs `CUSTOM_SHADER_COMPILE_FAILED` with the GLSL log (see [`troubleshooting.md`](troubleshooting.md)).
|
|
180
|
+
|
|
181
|
+
## 6. A ball dribbled by hand (hand and floor contact, handoffs, proof)
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
import { createHandBounce, measureHandBall } from "@aura3d/engine/game";
|
|
185
|
+
|
|
186
|
+
const bounce = createHandBounce({ hand: "right", ballRadius: 0.13, floorY: 0 });
|
|
187
|
+
const samples: Array<{ phase: number; moving: boolean; palm: [number, number, number]; ball: [number, number, number] }> = [];
|
|
188
|
+
|
|
189
|
+
// fixed sim step, once per frame while the handler holds the ball
|
|
190
|
+
function stepDribble(dt: number, holder: { x: number; z: number; vx: number; vz: number }, shoulderY: number, palm: [number, number, number]) {
|
|
191
|
+
const frame = bounce.step(dt, { speed: Math.hypot(holder.vx, holder.vz), shoulderY, palm, holder });
|
|
192
|
+
samples.push({ phase: frame.phase, moving: frame.moving, palm, ball: frame.ball });
|
|
193
|
+
return frame.ball; // put the ball node here; frame.floorContact is the moment to play the bounce cue
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// pass or shot: start the flight from the hand with the ball's own velocity, so it leaves without a pop
|
|
197
|
+
const { position, velocity } = bounce.release();
|
|
198
|
+
|
|
199
|
+
// prove it after a few seconds of dribble (human and AI handlers, standing and running)
|
|
200
|
+
const report = measureHandBall(samples);
|
|
201
|
+
if (!report.ok) throw new Error(report.failures.join("; "));
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Palm and ball ride together, the ball bounces directly under the palm (a runner's ball lands ahead along travel by at most 0.10 m), hits the floor with its centre at the ball radius, and the palm follows it down and receives it every bounce. Always pass the palm as drawn, whatever pose the athlete is in. `bounce.overlay()` gives the upper-body bend (never the legs). See [motion-hand-bounce](../api/motion-hand-bounce.md).
|
|
205
|
+
|
|
206
|
+
## 7. A bat, club or racket held in both hands (grip, hand-off, proof)
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
import { calibrateGripSocket, createTwoHandGrip, measureGrip, twoHandGripBonesForEngine, gripQToEulerZYX } from "@aura3d/engine/game";
|
|
210
|
+
|
|
211
|
+
const rig = { ...body, ...calibrateGripSocket(body, stance), gripSpread: 0.09, objectLength: 1.02, sweetSpot: 0.73 };
|
|
212
|
+
const grip = createTwoHandGrip(rig);
|
|
213
|
+
const samples: Array<{ phase: string; knob: [number, number, number]; axis: [number, number, number]; bottomPalm: [number, number, number]; topPalm: [number, number, number] }> = [];
|
|
214
|
+
|
|
215
|
+
// fixed sim step, once per frame while the object is held
|
|
216
|
+
function stepSwing(target: { K: [number, number, number]; D: [number, number, number]; phi: number; hip: [number, number, number]; pole: [number, number, number] }, phase: string) {
|
|
217
|
+
const pose = grip.step(target);
|
|
218
|
+
actor.setAnimationPose({ bones: twoHandGripBonesForEngine(pose.bones), mode: "overlay" });
|
|
219
|
+
object.setPosition(...pose.palm).setRotation(...gripQToEulerZYX(pose.objectRot)); // route nodes use ZYX
|
|
220
|
+
samples.push({ phase, knob: pose.palm, axis: pose.objectDir, bottomPalm: pose.palm, topPalm: pose.palmL });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const report = measureGrip(samples);
|
|
224
|
+
if (!report.ok) throw new Error(report.failures.join("; "));
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Both drawn palms stay on the handle in every phase (stance, load, swing, follow-through); `grip.step(target, release)` lets the top hand go for a one-handed finish. See [motion-two-hand-grip](../api/motion-two-hand-grip.md).
|
|
228
|
+
|
|
229
|
+
## Procedural scenes with no admitted models (recipes 8 to 15)
|
|
230
|
+
|
|
231
|
+
Use these when the prompt names a world, a creature, a prop set or an effect and no GLB has been admitted. Every recipe is a complete `main.ts` in [`recipes-src/`](recipes-src/), typechecked with `tsc` against the engine and captured on a real GPU (Chrome 156, Apple M4 Max through ANGLE/Metal, `capture_browser_scenario` at 1280x720) with zero console events; the capture is `recipes-src/<name>/frame.png`. One capture on one GPU proves the recipe draws a readable frame, not parity or a quality score. The sources below are copies: `recipes-src/<name>/main.ts` is the checked version.
|
|
232
|
+
|
|
233
|
+
Build and view any of them from the monorepo: `pnpm exec vite build --config docs/agents/recipes-src/vite.config.ts` then `pnpm exec vite preview --config docs/agents/recipes-src/vite.config.ts` and open `/<name>/index.html`. In a scaffolded project put `main.ts` in `src/` (the imports are the same).
|
|
234
|
+
|
|
235
|
+
### 8. A jointed robot from primitives with an idle animation
|
|
236
|
+
|
|
237
|
+
- Goal: A character with no GLB: capsules, spheres and boxes nested in `group` joints (hips, chest, head, shoulders, elbows, hips, knees) whose handles are driven from `onFrame`.
|
|
238
|
+
- Final frame: A steel-grey robot with a glowing cyan visor and chest core stands on a dark floor, three-quarter view; chest, head and arms sway slowly.
|
|
239
|
+
- Replaces in three.js: `Bone` hierarchy + `SkinnedMesh` + an idle `AnimationMixer` clip.
|
|
240
|
+
- Look and light values: `environments.neutral` 0.7, warm key `directional` 2.2 with shadow, blue rim `directional` 2.2, floor `#15171d`.
|
|
241
|
+
- Capture: `recipes-src/procedural-humanoid/frame.png`; source: `recipes-src/procedural-humanoid/main.ts`.
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
import { camera, createAuraApp, environments, group, interactions, lights, material, primitives, scene } from "@aura3d/engine";
|
|
245
|
+
|
|
246
|
+
// A robot from primitives with a real joint hierarchy: every joint is a `group` whose origin is the pivot, so rotating
|
|
247
|
+
// the shoulder swings the whole arm and rotating the elbow swings only the forearm. The idle animation drives the joints
|
|
248
|
+
// from onFrame. No GLB and no asset admission.
|
|
249
|
+
const steel = material.pbr({ color: "#b9c1ce", metallic: 0.55, roughness: 0.38 });
|
|
250
|
+
const dark = material.pbr({ color: "#2a2e39", metallic: 0.6, roughness: 0.5 });
|
|
251
|
+
const glow = material.emissive({ color: "#8fefff", emissive: "#46dcff", emissiveIntensity: 2.4 });
|
|
252
|
+
|
|
253
|
+
// A limb segment hangs down from its joint: capsule centred half its length below the pivot.
|
|
254
|
+
const segment = (name: string, length: number, radius: number, mat = steel) =>
|
|
255
|
+
primitives.capsule({ name, size: [radius * 2, length, radius * 2], material: mat }).position(0, -length / 2, 0);
|
|
256
|
+
|
|
257
|
+
const arm = (side: "l" | "r", x: number) =>
|
|
258
|
+
group(`${side}-shoulder`, [
|
|
259
|
+
primitives.sphere({ name: `${side}-shoulder-ball`, size: 0.2, material: dark }),
|
|
260
|
+
segment(`${side}-upper-arm`, 0.34, 0.055),
|
|
261
|
+
group(`${side}-elbow`, [
|
|
262
|
+
primitives.sphere({ name: `${side}-elbow-ball`, size: 0.13, material: dark }),
|
|
263
|
+
segment(`${side}-forearm`, 0.32, 0.048),
|
|
264
|
+
primitives.sphere({ name: `${side}-hand`, size: 0.12, material: dark }).position(0, -0.37, 0)
|
|
265
|
+
], { position: [0, -0.36, 0] }).runtime({ id: `${side}-elbow`, mutable: true })
|
|
266
|
+
], { position: [x, 0.5, 0] }).runtime({ id: `${side}-shoulder`, mutable: true });
|
|
267
|
+
|
|
268
|
+
const leg = (side: "l" | "r", x: number) =>
|
|
269
|
+
group(`${side}-hip`, [
|
|
270
|
+
segment(`${side}-thigh`, 0.46, 0.07),
|
|
271
|
+
group(`${side}-knee`, [
|
|
272
|
+
primitives.sphere({ name: `${side}-knee-ball`, size: 0.15, material: dark }),
|
|
273
|
+
segment(`${side}-shin`, 0.44, 0.06),
|
|
274
|
+
primitives.box({ name: `${side}-foot`, size: [0.14, 0.07, 0.26], material: dark }).position(0, -0.48, 0.05)
|
|
275
|
+
], { position: [0, -0.47, 0] }).runtime({ id: `${side}-knee`, mutable: true })
|
|
276
|
+
], { position: [x, -0.03, 0] }).runtime({ id: `${side}-hip`, mutable: true });
|
|
277
|
+
|
|
278
|
+
const robot = group("robot", [
|
|
279
|
+
group("hips", [
|
|
280
|
+
primitives.box({ name: "pelvis", size: [0.34, 0.16, 0.2], material: dark }),
|
|
281
|
+
leg("l", -0.1),
|
|
282
|
+
leg("r", 0.1),
|
|
283
|
+
group("chest", [
|
|
284
|
+
primitives.box({ name: "torso", size: [0.46, 0.5, 0.25], material: steel }).position(0, 0.3, 0),
|
|
285
|
+
primitives.box({ name: "core", size: [0.12, 0.12, 0.02], material: glow }).position(0, 0.32, 0.13),
|
|
286
|
+
group("head", [
|
|
287
|
+
primitives.cylinder({ name: "neck", size: [0.08, 0.1, 0.08], material: dark }).position(0, 0.04, 0),
|
|
288
|
+
primitives.sphere({ name: "skull", size: 0.26, material: steel }).position(0, 0.2, 0),
|
|
289
|
+
primitives.box({ name: "visor", size: [0.2, 0.05, 0.03], material: glow }).position(0, 0.21, 0.115)
|
|
290
|
+
], { position: [0, 0.58, 0] }).runtime({ id: "head", mutable: true }),
|
|
291
|
+
arm("l", -0.31),
|
|
292
|
+
arm("r", 0.31)
|
|
293
|
+
], { position: [0, 0.1, 0] }).runtime({ id: "chest", mutable: true })
|
|
294
|
+
], { position: [0, 1.0, 0] }).runtime({ id: "hips", mutable: true })
|
|
295
|
+
]);
|
|
296
|
+
|
|
297
|
+
const app = createAuraApp("#app", {
|
|
298
|
+
scene: scene({ background: "#14171d" })
|
|
299
|
+
.add(environments.neutral({ intensity: 0.7, background: false }))
|
|
300
|
+
.add(lights.directional({ name: "key", position: [3.5, 5, 3.5], target: [0, 1, 0], intensity: 2.2, color: "#ffe9cf", shadow: true }))
|
|
301
|
+
.add(lights.directional({ name: "rim", position: [-4, 3, -4], target: [0, 1, 0], intensity: 2.2, color: "#7fb4ff" }))
|
|
302
|
+
.add(primitives.plane({ name: "floor", size: [14, 1, 14], material: material.pbr({ color: "#15171d", roughness: 0.9 }) }))
|
|
303
|
+
.add(robot)
|
|
304
|
+
.add(interactions.orbit({ target: "floor" }))
|
|
305
|
+
.camera(camera.orbit({ position: [3, 1.5, 4.2], target: [0, 1.0, 0], fov: 36 }))
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
let t = 0;
|
|
309
|
+
app.onFrame((frame) => {
|
|
310
|
+
t += frame.dt;
|
|
311
|
+
const breathe = Math.sin(t * 1.6);
|
|
312
|
+
app.nodes.require("hips").setPosition(0, 1.0 + 0.012 * breathe, 0); // weight shift
|
|
313
|
+
app.nodes.require("chest").setRotation(0.03 * breathe, 0.1 * Math.sin(t * 0.7), 0);
|
|
314
|
+
app.nodes.require("head").setRotation(-0.04 * breathe, 0.35 * Math.sin(t * 0.5), 0);
|
|
315
|
+
app.nodes.require("l-shoulder").setRotation(0.05 * breathe, 0, 0.1 + 0.03 * breathe);
|
|
316
|
+
app.nodes.require("r-shoulder").setRotation(-0.05 * breathe, 0, -0.1 - 0.03 * breathe);
|
|
317
|
+
app.nodes.require("l-elbow").setRotation(-0.25 - 0.05 * breathe, 0, 0);
|
|
318
|
+
app.nodes.require("r-elbow").setRotation(-0.25 - 0.05 * breathe, 0, 0);
|
|
319
|
+
});
|
|
320
|
+
await app.ready();
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### 9. Heightfield island with vertex colours, water and fog
|
|
324
|
+
|
|
325
|
+
- Goal: One `geometry.custom` mesh built from a height function, coloured per vertex by height (sand, grass, rock, snow), a glossy translucent water plane, `effects.fog` and `sky.preetham`.
|
|
326
|
+
- Final frame: A snow-capped island with sand rim and green slopes in a blue sea under a pale sky, viewed from a corner of the bay.
|
|
327
|
+
- Replaces in three.js: `PlaneGeometry` + displaced vertices + `vertexColors` + `MeshStandardMaterial` water + `FogExp2` + `Sky`.
|
|
328
|
+
- Look and light values: `sky.preetham` sun 32 deg elevation, `hemisphere` 0.45, sun `directional` 2.4 with shadow, fog density 0.004.
|
|
329
|
+
- Capture: `recipes-src/terrain-water-fog/frame.png`; source: `recipes-src/terrain-water-fog/main.ts`.
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
import { camera, createAuraApp, effects, geometry, lights, material, primitives, scene, sky } from "@aura3d/engine";
|
|
333
|
+
|
|
334
|
+
// Heightfield island: one custom mesh with per-vertex colour from height, a glossy water plane, fog and a procedural sky.
|
|
335
|
+
const CELLS = 120; // quads per side: vertex colours and normals need vertices
|
|
336
|
+
const SIZE = 64; // metres per side
|
|
337
|
+
|
|
338
|
+
const height = (x: number, z: number): number => {
|
|
339
|
+
const rim = Math.hypot(x, z) / (SIZE * 0.5);
|
|
340
|
+
const hills = 4.4 * Math.sin(x * 0.13 + 1.3) * Math.cos(z * 0.1) + 1.5 * Math.sin(x * 0.29 + z * 0.21) + 0.45 * Math.sin(x * 0.7) * Math.sin(z * 0.63);
|
|
341
|
+
return hills + 3.2 - rim * rim * 9; // dome falloff: land in the middle, sea floor at the rim
|
|
342
|
+
};
|
|
343
|
+
const shade = (h: number): [number, number, number, number] =>
|
|
344
|
+
h < 0.45 ? [0.7, 0.6, 0.38, 1] : h < 3.4 ? [0.16, 0.36, 0.12, 1] : h < 5.4 ? [0.34, 0.28, 0.22, 1] : [0.9, 0.92, 0.95, 1]; // sand, grass, rock, snow
|
|
345
|
+
|
|
346
|
+
const positions = new Float32Array((CELLS + 1) ** 2 * 3);
|
|
347
|
+
const normals = new Float32Array(positions.length);
|
|
348
|
+
const colors = new Float32Array((CELLS + 1) ** 2 * 4);
|
|
349
|
+
const indices: number[] = [];
|
|
350
|
+
for (let j = 0; j <= CELLS; j += 1) for (let i = 0; i <= CELLS; i += 1) {
|
|
351
|
+
const v = j * (CELLS + 1) + i;
|
|
352
|
+
const x = (i / CELLS - 0.5) * SIZE;
|
|
353
|
+
const z = (j / CELLS - 0.5) * SIZE;
|
|
354
|
+
const h = height(x, z);
|
|
355
|
+
positions.set([x, h, z], v * 3);
|
|
356
|
+
const e = 0.2; // central differences give a smooth normal
|
|
357
|
+
const nx = height(x - e, z) - height(x + e, z);
|
|
358
|
+
const nz = height(x, z - e) - height(x, z + e);
|
|
359
|
+
const len = Math.hypot(nx, 2 * e, nz);
|
|
360
|
+
normals.set([nx / len, (2 * e) / len, nz / len], v * 3);
|
|
361
|
+
colors.set(shade(h), v * 4);
|
|
362
|
+
if (i < CELLS && j < CELLS) indices.push(v, v + CELLS + 1, v + 1, v + 1, v + CELLS + 1, v + CELLS + 2);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const app = createAuraApp("#app", {
|
|
366
|
+
scene: scene({ background: "#9fc4e8" })
|
|
367
|
+
.add(sky.preetham({ sun: { elevationDeg: 32, azimuthDeg: 130, intensity: 1 } }))
|
|
368
|
+
.add(lights.hemisphere({ skyColor: "#cfe3ff", groundColor: "#4d5a3c", intensity: 0.45 }))
|
|
369
|
+
.add(lights.directional({ name: "sun", position: [30, 26, 22], target: [0, 0, 0], intensity: 2.4, color: "#fff1d6", shadow: true }))
|
|
370
|
+
.add(geometry.custom(
|
|
371
|
+
geometry.define({ positions, normals, colors, indices, bounds: { min: [-SIZE / 2, -8, -SIZE / 2], max: [SIZE / 2, 8, SIZE / 2] } }),
|
|
372
|
+
{ name: "terrain", material: material.pbr({ color: "#ffffff", roughness: 0.95 }) }
|
|
373
|
+
))
|
|
374
|
+
.add(primitives.plane({ name: "water", size: [SIZE * 2, 1, SIZE * 2], material: material.pbr({ color: "#2b79a8", roughness: 0.06, metallic: 0.1, opacity: 0.82 }), castShadow: false }).position(0, 0, 0))
|
|
375
|
+
.add(effects.fog({ color: "#b7d2ea", density: 0.004 }))
|
|
376
|
+
.camera(camera.perspective({ position: [30, 13, 34], target: [0, 1, 0], fov: 45, near: 0.5, far: 400 }))
|
|
377
|
+
});
|
|
378
|
+
await app.ready();
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
### 10. Stylised props kit with instancing (rocks, pines, houses)
|
|
382
|
+
|
|
383
|
+
- Goal: Seeded layout of rocks, pines (trunk plus two cones) and houses (box plus four-sided cone roof); five instanced nodes, per-instance colour from a white base material.
|
|
384
|
+
- Final frame: A ring of pine forest around a small village of cream houses with red roofs, scattered grey rocks, on a green meadow, high three-quarter view.
|
|
385
|
+
- Replaces in three.js: Five `InstancedMesh` nodes with `setMatrixAt` and `setColorAt`.
|
|
386
|
+
- Look and light values: `environments.neutral` 0.45, `hemisphere` 0.35, sun `directional` 2.2 with shadow, fog density 0.004.
|
|
387
|
+
- Capture: `recipes-src/props-kit-instances/frame.png`; source: `recipes-src/props-kit-instances/main.ts`.
|
|
388
|
+
|
|
389
|
+
```ts
|
|
390
|
+
import { camera, createAuraApp, effects, environments, instances, lights, material, primitives, scene, type AuraTransformSpec } from "@aura3d/engine";
|
|
391
|
+
|
|
392
|
+
// A stylised village from unit primitives: rocks, pine trees and houses are each ONE instanced draw (colour per instance).
|
|
393
|
+
let seed = 20261105; // seeded RNG: the same layout on every load
|
|
394
|
+
const rand = (): number => ((seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 4294967296);
|
|
395
|
+
const between = (a: number, b: number): number => a + (b - a) * rand();
|
|
396
|
+
|
|
397
|
+
const rocks: AuraTransformSpec[] = [];
|
|
398
|
+
const rockColors: string[] = [];
|
|
399
|
+
const trunks: AuraTransformSpec[] = [];
|
|
400
|
+
const pines: AuraTransformSpec[] = [];
|
|
401
|
+
const pineColors: string[] = [];
|
|
402
|
+
const walls: AuraTransformSpec[] = [];
|
|
403
|
+
const roofs: AuraTransformSpec[] = [];
|
|
404
|
+
const wallColors: string[] = [];
|
|
405
|
+
const roofColors: string[] = [];
|
|
406
|
+
|
|
407
|
+
for (let i = 0; i < 70; i += 1) { // rocks: squashed spheres, tilted, grey with a little variation
|
|
408
|
+
const a = rand() * Math.PI * 2, r = between(6, 34), s = between(0.3, 1.1);
|
|
409
|
+
rocks.push({ position: [Math.cos(a) * r, s * 0.25, Math.sin(a) * r], rotation: [0, rand() * 6, 0], scale: [s * 1.5, s * 0.9, s * 1.2] });
|
|
410
|
+
rockColors.push(`hsl(30, 6%, ${38 + Math.floor(rand() * 22)}%)`);
|
|
411
|
+
}
|
|
412
|
+
for (let i = 0; i < 110; i += 1) { // pines on a ring around the village, two cones per tree for a layered crown
|
|
413
|
+
const a = rand() * Math.PI * 2, r = between(15, 40), s = between(0.7, 1.3);
|
|
414
|
+
const x = Math.cos(a) * r, z = Math.sin(a) * r;
|
|
415
|
+
trunks.push({ position: [x, 0.6 * s, z], scale: [0.3 * s, 1.2 * s, 0.3 * s] });
|
|
416
|
+
pines.push({ position: [x, 2.1 * s, z], scale: [2 * s, 3 * s, 2 * s] }, { position: [x, 3.4 * s, z], scale: [1.4 * s, 2.4 * s, 1.4 * s] });
|
|
417
|
+
const crown = `hsl(${135 + Math.floor(rand() * 25)}, 45%, ${20 + Math.floor(rand() * 12)}%)`;
|
|
418
|
+
pineColors.push(crown, crown);
|
|
419
|
+
}
|
|
420
|
+
for (let gx = -2; gx <= 2; gx += 1) for (let gz = -2; gz <= 2; gz += 1) { // 5x5 plots, some left empty
|
|
421
|
+
if (Math.abs(gx) + Math.abs(gz) > 3 || rand() < 0.25) continue;
|
|
422
|
+
const w = between(2.2, 3.2), h = between(1.8, 3), d = between(2.2, 3.4);
|
|
423
|
+
const x = gx * 5 + between(-0.6, 0.6), z = gz * 5 + between(-0.6, 0.6), yaw = between(-0.25, 0.25);
|
|
424
|
+
walls.push({ position: [x, h / 2, z], rotation: [0, yaw, 0], scale: [w, h, d] });
|
|
425
|
+
roofs.push({ position: [x, h + 0.75, z], rotation: [0, yaw + Math.PI / 4, 0], scale: [Math.max(w, d) * 1.05, 1.5, Math.max(w, d) * 1.05] });
|
|
426
|
+
wallColors.push(`hsl(${30 + Math.floor(rand() * 20)}, 35%, ${68 + Math.floor(rand() * 14)}%)`);
|
|
427
|
+
roofColors.push(`hsl(${5 + Math.floor(rand() * 15)}, 55%, ${34 + Math.floor(rand() * 10)}%)`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const white = material.pbr({ color: "#ffffff", roughness: 0.9 }); // instance colours multiply this base colour once
|
|
431
|
+
const app = createAuraApp("#app", {
|
|
432
|
+
scene: scene({ background: "#b9d3e6" })
|
|
433
|
+
.add(environments.neutral({ intensity: 0.45, background: false }))
|
|
434
|
+
.add(lights.hemisphere({ skyColor: "#dbe9ff", groundColor: "#5b5a3a", intensity: 0.35 }))
|
|
435
|
+
.add(lights.directional({ name: "sun", position: [28, 40, 18], target: [0, 0, 0], intensity: 2.2, color: "#fff0d8", shadow: true }))
|
|
436
|
+
.add(effects.fog({ color: "#b9d3e6", density: 0.004 }))
|
|
437
|
+
.add(primitives.plane({ name: "ground", size: [120, 1, 120], material: material.pbr({ color: "#6f8f4c", roughness: 1 }) }))
|
|
438
|
+
.add(instances.sphere({ name: "rocks", transforms: rocks, colors: rockColors, material: white }))
|
|
439
|
+
.add(instances.cylinder({ name: "trunks", transforms: trunks, material: material.pbr({ color: "#5a3d24", roughness: 0.95 }), params: { radialSegments: 6 } }))
|
|
440
|
+
.add(instances.cone({ name: "pines", transforms: pines, colors: pineColors, material: white, params: { radialSegments: 8 } }))
|
|
441
|
+
.add(instances.box({ name: "walls", transforms: walls, colors: wallColors, material: white }))
|
|
442
|
+
.add(instances.cone({ name: "roofs", transforms: roofs, colors: roofColors, material: white, params: { radialSegments: 4 } }))
|
|
443
|
+
.camera(camera.perspective({ position: [26, 21, 30], target: [0, 1.5, 0], fov: 42, near: 1, far: 300 }))
|
|
444
|
+
});
|
|
445
|
+
await app.ready();
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
### 11. PBR product viewer on a procedural pedestal
|
|
449
|
+
|
|
450
|
+
- Goal: A clear-coated gold torus knot on a cylinder plinth and round stage, three-point light, neutral environment, slow turntable and orbit control.
|
|
451
|
+
- Final frame: A gold knot sculpture on a white plinth against a light grey backdrop, soft contact with the stage, metal reflections visible.
|
|
452
|
+
- Replaces in three.js: `TorusKnotGeometry` + `MeshPhysicalMaterial` + `PMREMGenerator` environment + `OrbitControls`.
|
|
453
|
+
- Look and light values: `environments.neutral` 1.15, key 2.8, fill 1.0, rim 1.6; knot `metallic: 1, roughness: 0.22, clearcoat: 0.8`.
|
|
454
|
+
- Capture: `recipes-src/pbr-product-viewer/frame.png`; source: `recipes-src/pbr-product-viewer/main.ts`.
|
|
455
|
+
|
|
456
|
+
```ts
|
|
457
|
+
import { camera, createAuraApp, environments, interactions, lights, material, primitives, scene } from "@aura3d/engine";
|
|
458
|
+
|
|
459
|
+
// A product on a procedural pedestal: torus knot (clear-coated metal), cylinder plinth, round stage, neutral environment.
|
|
460
|
+
// Metals reflect the environment, so environments.neutral is what makes this read as metal instead of grey plastic.
|
|
461
|
+
const app = createAuraApp("#app", {
|
|
462
|
+
scene: scene({ background: "#d9dde3" })
|
|
463
|
+
.add(environments.neutral({ intensity: 1.15, background: false }))
|
|
464
|
+
.add(lights.directional({ name: "key", position: [4, 6.5, 4.5], target: [0, 1, 0], intensity: 2.8, color: "#fff4e6", shadow: true }))
|
|
465
|
+
.add(lights.directional({ name: "fill", position: [-5, 3, 2], target: [0, 1, 0], intensity: 1, color: "#cfdcff" }))
|
|
466
|
+
.add(lights.directional({ name: "rim", position: [0, 4, -6], target: [0, 1, 0], intensity: 1.6, color: "#ffffff" }))
|
|
467
|
+
.add(primitives.cylinder({ name: "stage", size: [9, 0.2, 9], material: material.pbr({ color: "#eceff3", roughness: 0.55 }), castShadow: false }).position(0, -0.1, 0))
|
|
468
|
+
.add(primitives.cylinder({ name: "plinth", size: [2.2, 0.9, 2.2], material: material.pbr({ color: "#f7f8fa", roughness: 0.35 }) }).position(0, 0.45, 0))
|
|
469
|
+
.add(primitives.cylinder({ name: "plinth-ring", size: [2.32, 0.06, 2.32], material: material.pbr({ color: "#2a2d33", metallic: 0.9, roughness: 0.3 }) }).position(0, 0.93, 0))
|
|
470
|
+
.add(primitives.torusKnot({
|
|
471
|
+
name: "product",
|
|
472
|
+
size: 0.72,
|
|
473
|
+
params: { tubularSegments: 220, radialSegments: 32, tubeRadius: 0.2, p: 2, q: 3 },
|
|
474
|
+
material: material.physical({ color: "#c9a46a", metallic: 1, roughness: 0.22, clearcoat: 0.8, clearcoatRoughness: 0.08 })
|
|
475
|
+
}).position(0, 1.8, 0).runtime({ id: "product", mutable: true }))
|
|
476
|
+
.add(interactions.orbit({ target: "product" }))
|
|
477
|
+
.camera(camera.orbit({ position: [4.6, 3.1, 6], target: [0, 1.5, 0], fov: 34 }))
|
|
478
|
+
});
|
|
479
|
+
let angle = 0;
|
|
480
|
+
app.onFrame((frame) => {
|
|
481
|
+
angle += frame.dt * 0.5;
|
|
482
|
+
app.nodes.require("product").setRotation(0, angle, 0); // slow turntable
|
|
483
|
+
});
|
|
484
|
+
await app.ready();
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
### 12. Water with a custom GLSL material (waves, fresnel, sun glint)
|
|
488
|
+
|
|
489
|
+
- Goal: A subdivided grid displaced in the vertex shader, an analytic normal, a fresnel mix between deep colour and sky, and a sun glint; time comes from a uniform written in `onFrame`.
|
|
490
|
+
- Final frame: Rolling teal and turquoise swell with pale crests receding to a light sky; a dark islet on the horizon. Frames differ over time.
|
|
491
|
+
- Replaces in three.js: `ShaderMaterial` with `uniforms.time` on a `PlaneGeometry(…, 200, 200)`.
|
|
492
|
+
- Look and light values: Background `#9ec3e6`, one `directional` 2.5 (only the islet is lit by it; the water shader computes its own colour), fog 0.008.
|
|
493
|
+
- Capture: `recipes-src/water-custom-shader/frame.png`; source: `recipes-src/water-custom-shader/main.ts`.
|
|
494
|
+
|
|
495
|
+
```ts
|
|
496
|
+
import { camera, createAuraApp, effects, geometry, lights, material, primitives, scene } from "@aura3d/engine";
|
|
497
|
+
|
|
498
|
+
// Water with a custom GLSL material: vertex waves, analytic normal, fresnel mix between deep colour and sky, sun glint.
|
|
499
|
+
// Uniforms listed in `uniforms` are auto-declared in GLSL. Prelude names: position/normal/uv (vertex attributes), modelViewProjectionMatrix, modelMatrix, cameraPosition, uTime (uTime advances only while the scene redraws every frame; this recipe drives its own `waveClock` from onFrame).
|
|
500
|
+
const N = 200, SIZE = 80;
|
|
501
|
+
const positions = new Float32Array((N + 1) ** 2 * 3);
|
|
502
|
+
const normals = new Float32Array(positions.length);
|
|
503
|
+
const indices: number[] = [];
|
|
504
|
+
for (let j = 0; j <= N; j += 1) for (let i = 0; i <= N; i += 1) {
|
|
505
|
+
const v = j * (N + 1) + i;
|
|
506
|
+
positions.set([(i / N - 0.5) * SIZE, 0, (j / N - 0.5) * SIZE], v * 3);
|
|
507
|
+
normals[v * 3 + 1] = 1;
|
|
508
|
+
if (i < N && j < N) indices.push(v, v + N + 1, v + 1, v + 1, v + N + 1, v + N + 2);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const water = material.custom({
|
|
512
|
+
glsl: {
|
|
513
|
+
vertex: /* glsl */ `
|
|
514
|
+
out vec3 vPos;
|
|
515
|
+
out vec3 vNormal;
|
|
516
|
+
vec3 wave(vec2 p, float t, out vec2 slope) {
|
|
517
|
+
float a = sin(dot(p, vec2(0.21, 0.13)) * 3.0 + t * 1.1) * 0.35;
|
|
518
|
+
float b = sin(dot(p, vec2(-0.17, 0.25)) * 3.0 + t * 1.4) * 0.22;
|
|
519
|
+
float c = sin(dot(p, vec2(0.4, 0.31)) * 3.0 + t * 2.1) * 0.08;
|
|
520
|
+
slope = vec2(cos(dot(p, vec2(0.21, 0.13)) * 3.0 + t * 1.1) * 0.35 * 0.63 + cos(dot(p, vec2(-0.17, 0.25)) * 3.0 + t * 1.4) * 0.22 * -0.51 + cos(dot(p, vec2(0.4, 0.31)) * 3.0 + t * 2.1) * 0.08 * 1.2,
|
|
521
|
+
cos(dot(p, vec2(0.21, 0.13)) * 3.0 + t * 1.1) * 0.35 * 0.39 + cos(dot(p, vec2(-0.17, 0.25)) * 3.0 + t * 1.4) * 0.22 * 0.75 + cos(dot(p, vec2(0.4, 0.31)) * 3.0 + t * 2.1) * 0.08 * 0.93);
|
|
522
|
+
return vec3(p.x, a + b + c, p.y);
|
|
523
|
+
}
|
|
524
|
+
void main() {
|
|
525
|
+
vec2 slope;
|
|
526
|
+
vec3 p = wave(position.xz, waveClock, slope);
|
|
527
|
+
vPos = p;
|
|
528
|
+
vNormal = normalize(vec3(-slope.x, 1.0, -slope.y));
|
|
529
|
+
gl_Position = modelViewProjectionMatrix * vec4(p, 1.0);
|
|
530
|
+
}`,
|
|
531
|
+
fragment: /* glsl */ `
|
|
532
|
+
in vec3 vPos;
|
|
533
|
+
in vec3 vNormal;
|
|
534
|
+
out vec4 outColor;
|
|
535
|
+
void main() {
|
|
536
|
+
vec3 n = normalize(vNormal);
|
|
537
|
+
vec3 v = normalize(cameraPosition - vPos); // cameraPosition is a prelude uniform in both stages
|
|
538
|
+
float fresnel = pow(1.0 - clamp(dot(n, v), 0.0, 1.0), 4.0);
|
|
539
|
+
vec3 deep = vec3(0.02, 0.18, 0.30);
|
|
540
|
+
vec3 shallow = vec3(0.10, 0.45, 0.55);
|
|
541
|
+
vec3 sky = vec3(0.62, 0.78, 0.92);
|
|
542
|
+
vec3 body = mix(deep, shallow, clamp(vPos.y * 1.4 + 0.5, 0.0, 1.0));
|
|
543
|
+
vec3 sunDir = normalize(vec3(0.5, 0.45, 0.35));
|
|
544
|
+
float glint = pow(max(dot(reflect(-sunDir, n), v), 0.0), 140.0);
|
|
545
|
+
outColor = vec4(mix(body, sky, clamp(fresnel * 0.9 + 0.08, 0.0, 1.0)) + vec3(1.0, 0.93, 0.78) * glint * 1.4, 1.0);
|
|
546
|
+
}`
|
|
547
|
+
},
|
|
548
|
+
uniforms: { waveClock: { type: "f32", value: 0 } }
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
const app = createAuraApp("#app", {
|
|
552
|
+
scene: scene({ background: "#9ec3e6" })
|
|
553
|
+
.add(lights.directional({ position: [5, 4.5, 3.5], target: [0, 0, 0], intensity: 2.5 }))
|
|
554
|
+
.add(geometry.custom(geometry.define({ positions, normals, indices, bounds: { min: [-SIZE / 2, -1, -SIZE / 2], max: [SIZE / 2, 1, SIZE / 2] } }), { name: "water", material: water, castShadow: false }).runtime({ id: "water", mutable: true }))
|
|
555
|
+
.add(primitives.sphere({ name: "islet", size: 1, material: material.pbr({ color: "#7a6a52", roughness: 1 }) }).position(9, -0.5, -14).scale([9, 3, 6]))
|
|
556
|
+
.add(effects.fog({ color: "#a9c9e6", density: 0.008 }))
|
|
557
|
+
.camera(camera.perspective({ position: [0, 6, 22], target: [0, 0, 0], fov: 50, near: 0.5, far: 300 }))
|
|
558
|
+
});
|
|
559
|
+
const start = performance.now();
|
|
560
|
+
app.onFrame(() => app.nodes.require("water").setUniform?.("waveClock", (performance.now() - start) / 1000));
|
|
561
|
+
await app.ready();
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
### 13. Procedural sky with a day and night cycle and moving shadows
|
|
565
|
+
|
|
566
|
+
- Goal: `sky.preetham` plus a sun and a moon `directional` light moved on arcs from `onFrame`; `?hour=17.2` freezes the time for captures.
|
|
567
|
+
- Final frame: A hut and scattered pines on a meadow in late-afternoon light, long shadows, a bright low sun glow on the left horizon.
|
|
568
|
+
- Replaces in three.js: `Sky` addon + `DirectionalLight` moved each frame + `shadow.camera` tuning.
|
|
569
|
+
- Look and light values: Sun `directional` 4.2 with shadow, moon `directional` 0.5, `hemisphere` 1.1, fog density 0.006.
|
|
570
|
+
- Capture: `recipes-src/sky-day-night/frame.png`; source: `recipes-src/sky-day-night/main.ts`.
|
|
571
|
+
|
|
572
|
+
```ts
|
|
573
|
+
import { camera, createAuraApp, effects, instances, lights, material, primitives, scene, sky, type AuraTransformSpec } from "@aura3d/engine";
|
|
574
|
+
|
|
575
|
+
// Procedural sky + moving sun and moon, with shadows that swing across a small meadow.
|
|
576
|
+
// ?hour=17.5 freezes the clock for a capture; without it a full day takes 90 seconds.
|
|
577
|
+
const fixedHour = new URLSearchParams(location.search).get("hour");
|
|
578
|
+
const DAY_SECONDS = 90;
|
|
579
|
+
|
|
580
|
+
let seed = 7;
|
|
581
|
+
const rand = (): number => ((seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 4294967296);
|
|
582
|
+
const trunks: AuraTransformSpec[] = [];
|
|
583
|
+
const crowns: AuraTransformSpec[] = [];
|
|
584
|
+
for (let i = 0; i < 28; i += 1) {
|
|
585
|
+
const a = rand() * Math.PI * 2, r = 14 + rand() * 24, s = 0.8 + rand() * 0.8;
|
|
586
|
+
trunks.push({ position: [Math.cos(a) * r, 0.9 * s, Math.sin(a) * r], scale: [0.35 * s, 1.8 * s, 0.35 * s] });
|
|
587
|
+
crowns.push({ position: [Math.cos(a) * r, 2.9 * s, Math.sin(a) * r], scale: [2.2 * s, 3 * s, 2.2 * s] });
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const sunDir = (hour: number): { elevationDeg: number; azimuthDeg: number } => ({
|
|
591
|
+
elevationDeg: Math.sin(((hour - 6) / 24) * Math.PI * 2) * 70, // 6h sunrise, 12h noon, 18h sunset
|
|
592
|
+
azimuthDeg: 90 + ((hour - 6) / 12) * 180
|
|
593
|
+
});
|
|
594
|
+
const lightAt = (elevationDeg: number, azimuthDeg: number, distance = 40): [number, number, number] => {
|
|
595
|
+
const e = (elevationDeg * Math.PI) / 180, a = (azimuthDeg * Math.PI) / 180;
|
|
596
|
+
return [Math.sin(a) * Math.cos(e) * distance, Math.sin(e) * distance, -Math.cos(a) * Math.cos(e) * distance];
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
const hour0 = fixedHour === null ? 15 : Number(fixedHour);
|
|
600
|
+
const s0 = sunDir(hour0);
|
|
601
|
+
const app = createAuraApp("#app", {
|
|
602
|
+
scene: scene({ background: "#7aa7d8" })
|
|
603
|
+
.add(sky.preetham({ sun: { ...s0, intensity: 1 }, stars: { count: 600 } }))
|
|
604
|
+
.add(lights.hemisphere({ skyColor: "#b8d0f0", groundColor: "#35422a", intensity: 1.1 }))
|
|
605
|
+
.add(lights.directional({ name: "sun", position: lightAt(s0.elevationDeg, s0.azimuthDeg), target: [0, 0, 0], intensity: 4.2, color: "#fff0d0", shadow: true }).runtime({ id: "sun", mutable: true }))
|
|
606
|
+
.add(lights.directional({ name: "moon", position: lightAt(-s0.elevationDeg, s0.azimuthDeg + 180), target: [0, 0, 0], intensity: 0.5, color: "#8fb0ff" }).runtime({ id: "moon", mutable: true }))
|
|
607
|
+
.add(primitives.plane({ name: "meadow", size: [90, 1, 90], material: material.pbr({ color: "#5d7f3f", roughness: 1 }) }))
|
|
608
|
+
.add(instances.cylinder({ name: "trunks", transforms: trunks, material: material.pbr({ color: "#5a3d24", roughness: 0.95 }), params: { radialSegments: 6 } }))
|
|
609
|
+
.add(instances.cone({ name: "crowns", transforms: crowns, material: material.pbr({ color: "#2f6b34", roughness: 0.9 }), params: { radialSegments: 8 } }))
|
|
610
|
+
.add(primitives.box({ name: "hut", material: material.pbr({ color: "#c9b79a", roughness: 0.9 }) }).position(0, 1.1, 0).scale([3.4, 2.2, 3]))
|
|
611
|
+
.add(effects.fog({ color: "#a9c4e0", density: 0.006 }))
|
|
612
|
+
.camera(camera.perspective({ position: [13, 5.5, 15], target: [0, 1.8, 0], fov: 46, near: 0.5, far: 400 }))
|
|
613
|
+
});
|
|
614
|
+
let clock = 0;
|
|
615
|
+
app.onFrame((frame) => {
|
|
616
|
+
clock += frame.dt;
|
|
617
|
+
const hour = fixedHour === null ? (hour0 + (clock / DAY_SECONDS) * 24) % 24 : hour0;
|
|
618
|
+
const s = sunDir(hour);
|
|
619
|
+
app.nodes.require("sun").setPosition(...lightAt(s.elevationDeg, s.azimuthDeg));
|
|
620
|
+
app.nodes.require("moon").setPosition(...lightAt(-s.elevationDeg, s.azimuthDeg + 180));
|
|
621
|
+
if (fixedHour === null) app.atmosphere?.setSky({ sun: { ...s, intensity: 1 } });
|
|
622
|
+
});
|
|
623
|
+
await app.ready();
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
### 14. Campfire particles with no sprite art (fire, smoke, sparks)
|
|
627
|
+
|
|
628
|
+
- Goal: Three `effects.particles` emitters using the built-in sprite atlas, a ring of instanced stones, two logs and a warm `point` light.
|
|
629
|
+
- Final frame: Orange flame blobs rising from two logs inside a stone ring, grey smoke above, a warm pool of light on a dark ground.
|
|
630
|
+
- Replaces in three.js: `Points` + `PointsMaterial` with a canvas texture, or a particle library.
|
|
631
|
+
- Look and light values: `point` 26 `#ff9a3c` distance 14, moon `directional` 0.7, neutral environment 0.12; flames `blend: "additive"`, smoke `blend: "alpha"`.
|
|
632
|
+
- Capture: `recipes-src/fire-smoke-sparks/frame.png`; source: `recipes-src/fire-smoke-sparks/main.ts`.
|
|
633
|
+
|
|
634
|
+
```ts
|
|
635
|
+
import { camera, createAuraApp, effects, environments, instances, lights, material, primitives, scene, type AuraTransformSpec } from "@aura3d/engine";
|
|
636
|
+
|
|
637
|
+
// A campfire with no sprite art: three effects.particles emitters (flames, smoke, sparks) that use the engine's
|
|
638
|
+
// built-in sprite atlas ("flame-loop", "smoke-a", "spark-streak"), logs and stones from primitives, a warm point light.
|
|
639
|
+
const stones: AuraTransformSpec[] = Array.from({ length: 10 }, (_, i) => {
|
|
640
|
+
const a = (i / 10) * Math.PI * 2;
|
|
641
|
+
return { position: [Math.cos(a) * 1.05, 0.12, Math.sin(a) * 1.05], rotation: [0, a, 0], scale: [0.42, 0.26, 0.34] };
|
|
642
|
+
});
|
|
643
|
+
const wood = material.pbr({ color: "#4a3020", roughness: 0.95 });
|
|
644
|
+
|
|
645
|
+
const app = createAuraApp("#app", {
|
|
646
|
+
scene: scene({ background: "#07090f" })
|
|
647
|
+
.add(environments.neutral({ intensity: 0.12, background: false }))
|
|
648
|
+
.add(lights.directional({ name: "moon", position: [-8, 10, -6], target: [0, 0, 0], intensity: 0.7, color: "#6f8fe0", shadow: true }))
|
|
649
|
+
.add(lights.point({ name: "firelight", position: [0, 1.0, 0], intensity: 26, color: "#ff9a3c", distance: 14, decay: 2, shadow: false }))
|
|
650
|
+
.add(primitives.plane({ name: "ground", size: [30, 1, 30], material: material.pbr({ color: "#2a2f24", roughness: 1 }) }))
|
|
651
|
+
.add(instances.sphere({ name: "stones", transforms: stones, material: material.pbr({ color: "#6b6b70", roughness: 0.95 }) }))
|
|
652
|
+
.add(primitives.cylinder({ name: "log-a", size: [0.24, 1.6, 0.24], material: wood }).position(0, 0.2, 0).rotate(0, 0.6, Math.PI / 2 - 0.12))
|
|
653
|
+
.add(primitives.cylinder({ name: "log-b", size: [0.24, 1.6, 0.24], material: wood }).position(0, 0.2, 0).rotate(0, -0.7, Math.PI / 2 + 0.12))
|
|
654
|
+
.add(effects.particles({ // scene-node emitters read name, position, rate, maxParticles, lifetime, speed, size, gravity, drag, seed, color, blend and sprite
|
|
655
|
+
name: "flames", position: [0, 0.25, 0], rate: 70, maxParticles: 220, lifetime: [0.6, 1.1],
|
|
656
|
+
speed: [0.9, 1.7], size: [0.22, 0.5], gravity: 0, drag: 0.3, blend: "additive", seed: 11, color: "#ff8a2a"
|
|
657
|
+
}))
|
|
658
|
+
.add(effects.particles({
|
|
659
|
+
name: "smoke", position: [0, 1.3, 0], rate: 14, maxParticles: 90, lifetime: [2.4, 3.8],
|
|
660
|
+
speed: [0.5, 0.9], size: [0.4, 1.3], gravity: 0, drag: 0.15, blend: "alpha", seed: 12, color: "#55545a"
|
|
661
|
+
}))
|
|
662
|
+
.add(effects.particles({
|
|
663
|
+
name: "sparks", position: [0, 0.6, 0], rate: 20, maxParticles: 80, lifetime: [0.8, 1.7],
|
|
664
|
+
speed: [2, 4.5], size: [0.05, 0.1], gravity: 3, sprite: "spark-streak", blend: "additive", seed: 13, color: "#ffb347"
|
|
665
|
+
}))
|
|
666
|
+
.camera(camera.perspective({ position: [3.6, 1.9, 4.6], target: [0, 1.1, 0], fov: 44, near: 0.1, far: 80 }))
|
|
667
|
+
});
|
|
668
|
+
await app.ready();
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
### 15. Night street with point lights, emissive windows and bloom
|
|
672
|
+
|
|
673
|
+
- Goal: Instanced towers and emissive window quads (two nodes: warm and cool), six lantern posts each with a real `point` light, a moon, dark fog, `effects.bloom`.
|
|
674
|
+
- Final frame: A dark street between two rows of towers with warm and blue lit windows, glowing lanterns with halos, orange pools on the wet road.
|
|
675
|
+
- Replaces in three.js: `EffectComposer` + `UnrealBloomPass` + `PointLight`s + emissive `MeshStandardMaterial`.
|
|
676
|
+
- Look and light values: Moon `directional` 1.1, neutral environment 0.25, lamps `point` 14 `#ffb866`, fog density 0.028, `bloom` intensity 1 threshold 0.6 radius 0.6.
|
|
677
|
+
- Capture: `recipes-src/night-lights-emissive-bloom/frame.png`; source: `recipes-src/night-lights-emissive-bloom/main.ts`.
|
|
678
|
+
|
|
679
|
+
```ts
|
|
680
|
+
import { camera, createAuraApp, effects, environments, instances, lights, material, primitives, scene, type AuraTransformSpec } from "@aura3d/engine";
|
|
681
|
+
|
|
682
|
+
// A night street: lantern posts with real point lights, glowing windows, a moon, fog and bloom.
|
|
683
|
+
// Emissive materials only look like light sources when bloom and fog give them a halo and depth.
|
|
684
|
+
const lampSpots: Array<[number, number]> = [[-4, 6], [-4, -2], [-4, -10], [4, 2], [4, -6], [4, -14]];
|
|
685
|
+
const warmWindows: AuraTransformSpec[] = [];
|
|
686
|
+
const coolWindows: AuraTransformSpec[] = [];
|
|
687
|
+
const towers: AuraTransformSpec[] = [];
|
|
688
|
+
let seed = 99;
|
|
689
|
+
const rand = (): number => ((seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 4294967296);
|
|
690
|
+
|
|
691
|
+
for (const side of [-1, 1]) for (let i = 0; i < 6; i += 1) {
|
|
692
|
+
const x = side * 9.5, z = 8 - i * 6.5, h = 6 + rand() * 7;
|
|
693
|
+
towers.push({ position: [x, h / 2, z], scale: [4.6, h, 5] });
|
|
694
|
+
for (let row = 0; row < Math.floor(h / 1.6); row += 1) for (let col = 0; col < 2; col += 1) {
|
|
695
|
+
if (rand() < 0.35) continue; // dark windows
|
|
696
|
+
(rand() < 0.7 ? warmWindows : coolWindows).push({ position: [x - side * 2.33, 1.2 + row * 1.5, z - 1.1 + col * 2.2], scale: [0.04, 0.7, 0.9] });
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const stage = scene({ background: "#05070d" })
|
|
701
|
+
.add(environments.neutral({ intensity: 0.25, background: false }))
|
|
702
|
+
.add(lights.directional({ name: "moonlight", position: [-10, 16, -10], target: [0, 0, -4], intensity: 1.1, color: "#7d9bff", shadow: true }))
|
|
703
|
+
.add(primitives.plane({ name: "street", size: [24, 1, 60], material: material.pbr({ color: "#1b1e26", roughness: 0.35, metallic: 0.2 }) }).position(0, 0, -6))
|
|
704
|
+
.add(instances.box({ name: "towers", transforms: towers, material: material.pbr({ color: "#262b36", roughness: 0.9 }) }))
|
|
705
|
+
.add(instances.box({ name: "windows-warm", transforms: warmWindows, material: material.emissive({ color: "#ffcf7a", emissive: "#ffb84d", emissiveIntensity: 1.6 }) })) // emissive colour comes from the material, not from instance colours
|
|
706
|
+
.add(instances.box({ name: "windows-cool", transforms: coolWindows, material: material.emissive({ color: "#9fd2ff", emissive: "#6fb8ff", emissiveIntensity: 1.6 }) }))
|
|
707
|
+
.add(primitives.sphere({ name: "moon", size: 3, material: material.emissive({ color: "#dfe8ff", emissive: "#dfe8ff", emissiveIntensity: 2.5 }) }).position(-16, 26, -42))
|
|
708
|
+
.add(effects.fog({ color: "#0a1020", density: 0.028 }))
|
|
709
|
+
.add(effects.bloom({ intensity: 1, threshold: 0.6, radius: 0.6 }))
|
|
710
|
+
.camera(camera.perspective({ position: [0, 3.2, 14], target: [0, 3.6, -12], fov: 46, near: 0.2, far: 160 }));
|
|
711
|
+
|
|
712
|
+
for (const [i, [x, z]] of lampSpots.entries()) {
|
|
713
|
+
stage.add(primitives.cylinder({ name: `post-${i}`, size: [0.12, 3.4, 0.12], material: material.pbr({ color: "#15171c", metallic: 0.6, roughness: 0.5 }) }).position(x, 1.7, z));
|
|
714
|
+
stage.add(primitives.sphere({ name: `lantern-${i}`, size: 0.42, material: material.emissive({ color: "#ffd9a0", emissive: "#ffb85c", emissiveIntensity: 4 }) }).position(x, 3.5, z));
|
|
715
|
+
stage.add(lights.point({ name: `lamp-${i}`, position: [x, 3.4, z], intensity: 14, color: "#ffb866", distance: 12, decay: 2, shadow: false }));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const app = createAuraApp("#app", { scene: stage });
|
|
719
|
+
await app.ready();
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
## Type traps (strict `tsc` errors agents hit on a first draft)
|
|
723
|
+
|
|
724
|
+
| Error | Cause | Fix |
|
|
725
|
+
|---|---|---|
|
|
726
|
+
| `TS18048: 'app.camera' is possibly 'undefined'` | `app.camera` is an optional app extension. | Move the camera with `app.camera?.setPose(...)`, or declare it in the scene (`camera.orbit(...)`, `camera.perspective(...)`) and use `interactions.orbit` for user control. |
|
|
727
|
+
| `TS2339: Property 'translate' does not exist on type 'AuraNodeBuilder<...>'` | `translate` is on the live runtime handle (`app.nodes.require("name")`), not on the builders (`primitives.box(...)`). | On a builder use `.position(x, y, z)` or `.position([x, y, z])` (the array form needs 4.2.0 or later; on 4.1.3 and earlier pass three numbers). After mounting, move it with the handle's `translate` or `setPosition`. |
|
|
728
|
+
| `position([x, y, z])` rejected on 4.1.3 and earlier | `.position`, `.rotate` and `.lookAt` took three numbers there while `.scale` took a number or an array. | Spread the array (`.position(...pos)`), or upgrade to 4.2.0 or later where both forms work. |
|
|
729
|
+
| `TS2353: ... 'kind' does not exist in type 'Omit<AuraCustomGeometrySpec, ...>'` | `geometry.define` adds the `kind` itself. | Pass only `{ positions, normals, indices, bounds }` (see recipe 5), then mount the result with `geometry.custom(...)`. |
|
|
730
|
+
| `TS2722: Cannot invoke an object which is possibly 'undefined'` on `.setUniform(...)` | `setUniform` exists only on nodes that carry a custom material, so the handle type marks it optional. | `app.nodes.require("water").setUniform?.("waveClock", t)` exactly as in recipe 5 (the node needs `.runtime({ id: "water", mutable: true })`). |
|
|
731
|
+
| `TS2322` on `material.custom({ glsl })` | `glsl` is an object (`{ fragment }`, optionally `vertex`), and uniforms are `{ type: "f32", value }` objects, not bare numbers. | Copy the water recipe above. |
|
|
732
|
+
|
|
733
|
+
## Where next
|
|
734
|
+
|
|
735
|
+
[`agent-quickstart.md`](agent-quickstart.md) (assets, verification, habit table), [`api-surface.md`](api-surface.md) (names by task), [`build-a-game.md`](build-a-game.md), [`art-direction.md`](art-direction.md), [`claims-and-boundaries.md`](claims-and-boundaries.md).
|