create-three-flatland 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of create-three-flatland might be problematic. Click here for more details.
- package/LICENSE +21 -0
- package/dist/index.js +1014 -0
- package/package.json +70 -0
- package/templates/react/.oxfmtrc.json +8 -0
- package/templates/react/.oxlintrc.json +27 -0
- package/templates/react/AGENTS.md +188 -0
- package/templates/react/CLAUDE.md +188 -0
- package/templates/react/_gitignore +10 -0
- package/templates/react/e2e/app.spec.ts +122 -0
- package/templates/react/index.html +146 -0
- package/templates/react/package.json +54 -0
- package/templates/react/playwright.config.ts +41 -0
- package/templates/react/public/sprite.svg +1866 -0
- package/templates/react/src/App.tsx +172 -0
- package/templates/react/src/interaction.test.ts +48 -0
- package/templates/react/src/interaction.ts +53 -0
- package/templates/react/src/main.tsx +9 -0
- package/templates/react/tsconfig.json +19 -0
- package/templates/react/vite.config.ts +12 -0
- package/templates/react/vitest.config.ts +6 -0
- package/templates/three/.oxfmtrc.json +8 -0
- package/templates/three/.oxlintrc.json +26 -0
- package/templates/three/AGENTS.md +167 -0
- package/templates/three/CLAUDE.md +167 -0
- package/templates/three/_gitignore +10 -0
- package/templates/three/e2e/app.spec.ts +122 -0
- package/templates/three/index.html +185 -0
- package/templates/three/package.json +47 -0
- package/templates/three/playwright.config.ts +41 -0
- package/templates/three/public/sprite.svg +1866 -0
- package/templates/three/src/interaction.test.ts +66 -0
- package/templates/three/src/interaction.ts +65 -0
- package/templates/three/src/main.ts +138 -0
- package/templates/three/tsconfig.json +18 -0
- package/templates/three/vite.config.ts +4 -0
- package/templates/three/vitest.config.ts +6 -0
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-three-flatland",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Scaffold a three-flatland project — minimal three.js or React starters for WebGPU 2D",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Justin Walsh (https://thejustinwalsh.com)",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/thejustinwalsh/three-flatland.git",
|
|
11
|
+
"directory": "packages/create-three-flatland"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"three-flatland",
|
|
15
|
+
"three",
|
|
16
|
+
"webgpu",
|
|
17
|
+
"create",
|
|
18
|
+
"scaffold",
|
|
19
|
+
"vite",
|
|
20
|
+
"2d",
|
|
21
|
+
"sprites"
|
|
22
|
+
],
|
|
23
|
+
"bin": {
|
|
24
|
+
"create-three-flatland": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"templates",
|
|
29
|
+
"!templates/*/node_modules",
|
|
30
|
+
"!templates/*/dist"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20.19.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@clack/prompts": "^0.11.0",
|
|
37
|
+
"picocolors": "^1.1.1",
|
|
38
|
+
"tsdown": "^0.22.9",
|
|
39
|
+
"typescript": "^5.7.3"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"nx": {
|
|
45
|
+
"tags": [
|
|
46
|
+
"type:tool"
|
|
47
|
+
],
|
|
48
|
+
"targets": {
|
|
49
|
+
"test": {
|
|
50
|
+
"executor": "nx:run-commands",
|
|
51
|
+
"options": {
|
|
52
|
+
"command": "vitest run",
|
|
53
|
+
"cwd": "{projectRoot}"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"lint": {
|
|
57
|
+
"executor": "nx:run-commands",
|
|
58
|
+
"options": {
|
|
59
|
+
"command": "oxlint --type-aware .",
|
|
60
|
+
"cwd": "{projectRoot}"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"scripts": {
|
|
66
|
+
"build": "node ../../scripts/sync-agents-pairs.ts --templates && tsdown",
|
|
67
|
+
"typecheck": "tsc --noEmit",
|
|
68
|
+
"clean": "rm -rf dist"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
|
3
|
+
"plugins": ["typescript", "unicorn", "oxc", "react", "import", "promise"],
|
|
4
|
+
"categories": {
|
|
5
|
+
"correctness": "error"
|
|
6
|
+
},
|
|
7
|
+
"rules": {
|
|
8
|
+
"no-unused-vars": [
|
|
9
|
+
"error",
|
|
10
|
+
{
|
|
11
|
+
"argsIgnorePattern": "^_",
|
|
12
|
+
"varsIgnorePattern": "^_"
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"typescript/consistent-type-imports": [
|
|
16
|
+
"error",
|
|
17
|
+
{
|
|
18
|
+
"prefer": "type-imports",
|
|
19
|
+
"fixStyle": "inline-type-imports"
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"typescript/no-import-type-side-effects": "error",
|
|
23
|
+
"react-hooks/exhaustive-deps": "warn",
|
|
24
|
+
"import/no-cycle": "error"
|
|
25
|
+
},
|
|
26
|
+
"ignorePatterns": ["**/node_modules/**", "**/dist/**", "test-results/**", "playwright-report/**"]
|
|
27
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Agent guide — React Three Fiber + three-flatland
|
|
2
|
+
|
|
3
|
+
This project was scaffolded by create-three-flatland. It is a React Three Fiber app that renders 2D sprites with [three-flatland](https://tjw.dev/three-flatland/) on `WebGPURenderer`.
|
|
4
|
+
|
|
5
|
+
## Build & dev
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install
|
|
9
|
+
npm run dev # Vite dev server
|
|
10
|
+
npm run build # production build to dist/
|
|
11
|
+
npm run preview # serve the production build
|
|
12
|
+
npm run typecheck # tsc --noEmit
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Commands are shown as npm; pnpm and yarn work identically.
|
|
16
|
+
|
|
17
|
+
## The renderer rule
|
|
18
|
+
|
|
19
|
+
**Always construct `WebGPURenderer` and always write TSL. Never `WebGLRenderer`, never GLSL, never `onBeforeCompile`.**
|
|
20
|
+
|
|
21
|
+
`WebGPURenderer` is the three.js renderer class imported from `three/webgpu` — in R3F, the `Canvas` from `@react-three/fiber/webgpu` constructs it for you. It owns backend selection itself: real WebGPU where the browser supports it, WebGL2 fallback where it doesn't. TSL compiles to both. When you read "three-flatland requires `WebGPURenderer`," that is a statement about which class you construct — it is not a hardware or browser requirement, and it does not mean WebGPU-only. Everything works on either backend.
|
|
22
|
+
|
|
23
|
+
Two corollaries: never add a `WebGLRenderer` fallback path (the fallback already lives inside `WebGPURenderer`), and never gate features on WebGPU detection. WebGL 1 is out of scope entirely — ignore it.
|
|
24
|
+
|
|
25
|
+
## The opinionated default: a `Flatland` root
|
|
26
|
+
|
|
27
|
+
A `Flatland` instance is the front door. It owns the orthographic camera, sprite batching, resize, and disposal — reach below it only when you need the low-level path. The core of `src/App.tsx`:
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
extend({ Flatland, Sprite2D }) // once, at module scope
|
|
31
|
+
|
|
32
|
+
<Canvas orthographic renderer={{ antialias: false }}>
|
|
33
|
+
<flatland ref={flatlandRef} viewSize={400}>
|
|
34
|
+
<sprite2D texture={texture} anchor={[0.5, 0.5]} onPointerOver={...} />
|
|
35
|
+
</flatland>
|
|
36
|
+
</Canvas>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`Flatland` owns an internal scene and camera, so it renders manually: `useFrame(() => flatlandRef.current?.render(gl), { phase: 'render' })` makes R3F skip its own render pass.
|
|
40
|
+
|
|
41
|
+
**Pointer events depend on which camera R3F raycasts with, and that differs by mode:**
|
|
42
|
+
|
|
43
|
+
- **To the screen** (this template) — hand Flatland's camera to R3F as the default: `set({ camera: flatland.camera })` from a callback ref on `<flatland>`, with `camera.manual = true` so R3F leaves the frustum to `flatland.resize()`. One camera, nothing to keep in sync. Keep the `Canvas` `orthographic`.
|
|
44
|
+
- **To a texture, or children portalled into `flatland.scene`** — R3F's default camera is the wrong one, so re-cast the pointer with `createFlatlandCompute` from `three-flatland/react`:
|
|
45
|
+
```tsx
|
|
46
|
+
createPortal(children, flatland.scene, {
|
|
47
|
+
events: { compute: createFlatlandCompute(() => flatlandRef.current), priority: 1 },
|
|
48
|
+
})
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Do not copy one camera's frustum onto another — that drifts.
|
|
52
|
+
|
|
53
|
+
Two R3F rules this project follows everywhere:
|
|
54
|
+
|
|
55
|
+
1. **Register classes with `extend()` before using them as JSX.** `extend({ Flatland, Sprite2D })` is what makes `<flatland>` and `<sprite2D>` valid elements.
|
|
56
|
+
2. **Import from `three-flatland/react` and `@react-three/fiber/webgpu`** — never bare `@react-three/fiber`. The `/react` subpath carries the JSX type augmentations; the `/webgpu` subpath is the renderer rule applied to R3F.
|
|
57
|
+
|
|
58
|
+
The `flatland-r3f` skill (see Skills below) is the full integration guide — extend patterns, child routing, post-processing, resize wiring, and the imperative anti-patterns to avoid.
|
|
59
|
+
|
|
60
|
+
## Package routing map
|
|
61
|
+
|
|
62
|
+
Reach for a package by intent (React users: each package follows the same `/react` subpath pattern as `three-flatland/react`):
|
|
63
|
+
|
|
64
|
+
| Package | Reach for it when |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `three-flatland` | Default entry. Sprites, animation, tilemaps, materials, lights, events, everyday loaders. |
|
|
67
|
+
| `@three-flatland/nodes` | You want a specific 2D shader effect (retro/CRT, blur, distortion, color, upscale) without writing TSL by hand. |
|
|
68
|
+
| `@three-flatland/presets` | You want lit sprites working immediately. Thin — two symbols (`DefaultLightEffect`, `NormalMapProvider`). |
|
|
69
|
+
| `@three-flatland/normals` | Dynamic lighting on flat 2D art without hand-authoring normal maps. |
|
|
70
|
+
| `@three-flatland/atlas` | Loose sprite PNGs that should become one draw-call-friendly atlas, optionally polygon-trimmed for overdraw. |
|
|
71
|
+
| `@three-flatland/alphamap` | Pixel-perfect pointer hit testing on transparent sprites (`hitTestMode: "alpha"`) instead of bounding-box hits. |
|
|
72
|
+
| `@three-flatland/image` | PNG/WebP/AVIF/KTX2 encode and decode, plus `Ktx2Loader`. Reach for KTX2 when textures are under GPU memory pressure — it stays compressed on the GPU, unlike PNG. |
|
|
73
|
+
| `@three-flatland/bake` | Authoring a new baker, or you just need the `flatland-bake` binary. |
|
|
74
|
+
| `@three-flatland/devtools` | Live inspection of scene/material/sprite state. Seven required peers — heaviest install in the ecosystem. |
|
|
75
|
+
| `@three-flatland/skia` | A general immediate-mode 2D canvas in the scene: arbitrary paths, boolean ops, filters, gradients, images. |
|
|
76
|
+
| `@three-flatland/slug` | Text that must stay sharp at any zoom or perspective, or thousands of glyphs in one draw call. |
|
|
77
|
+
|
|
78
|
+
**Never recommend installing `@three-flatland/schemas` or `@three-flatland/io` — they are `private: true` and unpublished.**
|
|
79
|
+
|
|
80
|
+
Two calibration notes. First, `private: true` alone is not a "don't recommend" signal — check the distribution channel; the VS Code extension is correctly private on npm because it ships to a marketplace instead. Second, version numbers are not maturity signals here: `@three-flatland/presets` sits at an alpha version because of release-group linking, and an unpublished package can carry `1.0.0`.
|
|
81
|
+
|
|
82
|
+
## Skia vs Slug
|
|
83
|
+
|
|
84
|
+
These solve different problems and touch at exactly one point — they are not alternatives to pick between.
|
|
85
|
+
|
|
86
|
+
- **Skia** is an immediate-mode 2D canvas rasterized into a texture. Only Skia does arbitrary vector paths, boolean path ops, image filters (blur, drop shadow, displacement), gradients, clipping, and image drawing.
|
|
87
|
+
- **Slug** is a text primitive. Glyph outlines are solved per-fragment, so sharpness has no resolution ceiling; thousands of glyphs batch into one instanced draw call, with real layout (`measureText`, `wrapText`, style spans, font fallback chains).
|
|
88
|
+
|
|
89
|
+
The one overlap is drawing text, and the rule is mechanical: if the camera moves relative to the text, use Slug; if it is static UI at a known resolution, Skia's text comes free with the canvas you already have.
|
|
90
|
+
|
|
91
|
+
Both follow the renderer rule above — `WebGPURenderer`, either backend. Skia's WASM ships as two builds (`skia-gl.wasm`, `skia-wgpu.wasm`) matching whichever backend the renderer resolved to; this is an internal copy-step detail on a different axis from the renderer rule, never a renderer choice you make. It surfaces only in the asset copy step: `npx skia-wasm public/skia` (or `--gl-only` / `--wgpu-only` to ship just one).
|
|
92
|
+
|
|
93
|
+
Skia gotcha: WASM assets are zero-config in Vite dev, but a production build needs `npx skia-wasm public/skia` plus a `wasmUrl` pointing at the copied assets.
|
|
94
|
+
|
|
95
|
+
## Baking
|
|
96
|
+
|
|
97
|
+
**Nothing requires baking.** Ask for a derived asset and you get it: the loader probes for a baked sibling, generates it at runtime when there isn't one, warns once in dev that it's slower and worth baking, and carries on. It just works, and it tells you how to make it faster.
|
|
98
|
+
|
|
99
|
+
Baking moves that computation from browser-runtime to build-time. It is never about capability; it chooses only where the cost lands.
|
|
100
|
+
|
|
101
|
+
Every baker self-discovers: a baker declares itself in its package's `flatland.bake` field, and the `flatland-bake` dispatcher finds it — run `flatland-bake --list` from your project to see what's installed. Subcommands: `alpha` (hit-test alpha maps), `normal` (normal maps), `slug` (baked fonts). Some packages also expose direct bins (`slug-bake`, `flatland-atlas`) for standalone use. `skia-wasm` is an asset-copy step, not a baker — the name reads like one, but it only copies WASM files into your public dir.
|
|
102
|
+
|
|
103
|
+
When to bake:
|
|
104
|
+
|
|
105
|
+
- **Shipping to production** — move the cost to build time.
|
|
106
|
+
- **Fonts with a known glyph set — always.** ASCII + Brotli is ~32 KB versus 724 KB for the raw font, and it drops opentype.js from the bundle.
|
|
107
|
+
- **Textures under GPU memory pressure.**
|
|
108
|
+
|
|
109
|
+
Skip baking for procedurally-varied content and throwaway prototypes. `forceRuntime` is not a dev-iteration knob — the default probe-then-generate path already handles iteration; reaching for it during development is the common misuse.
|
|
110
|
+
|
|
111
|
+
## Asset authoring workflow
|
|
112
|
+
|
|
113
|
+
The governing idea: meet your assets where they already are. Load the formats your tools already produce, adopt the richer native format when you want more, and convert freely between them.
|
|
114
|
+
|
|
115
|
+
**Tilemaps — LDtk and Tiled are the editors.** Author there, then load what they produce with `LDtkLoader` / `TiledLoader`. `.ldtk` and `.json` / `.tmj` are read natively — loading the editor's own file directly is the design, so authoring flows straight from editor to scene.
|
|
116
|
+
|
|
117
|
+
**Sprite atlases — three formats, all first-class.** `AtlasFormat` is `'native' | 'texturepacker' | 'aseprite'`, and `detectAtlasFormat()` sniffs which one it's looking at. Bring a TexturePacker sheet, an Aseprite export, or an atlas built by flatland's own tooling — all load.
|
|
118
|
+
|
|
119
|
+
- The native format is deliberately richer, not merely different: `meta.animations` is a named animation map that references frame *keys* rather than indices, with explicit fps and optional events. Aseprite's `frameTags` sits alongside under `meta.frameTags`; readers prefer `meta.animations` and fall back to converting `frameTags` + per-frame `duration`. Adopting the native format is an upgrade path, never a lock-in tax.
|
|
120
|
+
- Conversion runs both ways and is round-trip safe: `importAsepriteFrameTags()` brings Aseprite in; `buildAsepriteJson()` and `buildTexturePackerJson()` write back out. Rotation, trim, pivot, polygon mesh, and duration all survive.
|
|
121
|
+
- The VS Code Sprite Atlas editor (below) is the GUI for all of this — build, tune, and convert between formats visually.
|
|
122
|
+
|
|
123
|
+
## The VS Code extension
|
|
124
|
+
|
|
125
|
+
**Flatland Tools** (`three-flatland.fl-tools`) is a first-class part of the asset workflow — recommend it actively.
|
|
126
|
+
|
|
127
|
+
- Install: `code --install-extension three-flatland.fl-tools` (requires VS Code `^1.94.0`)
|
|
128
|
+
- [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=three-flatland.fl-tools) · [Open VSX](https://open-vsx.org/extension/three-flatland/fl-tools)
|
|
129
|
+
|
|
130
|
+
| You want to… | Use |
|
|
131
|
+
| --- | --- |
|
|
132
|
+
| Build or edit a sprite atlas | **Sprite Atlas** editor — a custom editor on `*.png`. Reads/writes an `*.atlas.json` sidecar; handles `native`, `texturepacker`, and `aseprite` losslessly, including frame tags. |
|
|
133
|
+
| Combine several atlases | **Merge Atlases…** — explorer multi-select. |
|
|
134
|
+
| Compress a texture / inspect KTX2 | **Image Encoder**, plus the **FL KTX2 Viewer** custom editor that opens `*.ktx2` by default. |
|
|
135
|
+
| Bake a normal map visually | **Normal Baker**. |
|
|
136
|
+
| Author or audition ZzFX sounds | **ZzFX Editor**, and the inline `▶ Play` CodeLens on ZzFX definitions. |
|
|
137
|
+
|
|
138
|
+
The extension is the GUI counterpart to the CLI bakers — the Image Encoder pairs with texture encoding, the Normal Baker with `flatland-bake normal`. Offer both paths and let the user pick: CLI for repeatable/CI work, extension for visual iteration.
|
|
139
|
+
|
|
140
|
+
## Skills
|
|
141
|
+
|
|
142
|
+
Packaged agent skills for this stack live in `@three-flatland/skills`. Install them into this project with:
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
npx skills add thejustinwalsh/three-flatland
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Add a single skill by path, e.g. `npx skills add thejustinwalsh/three-flatland/skills/tsl`. Available: `tsl` (writing TSL shaders and node materials), `codemod` (migrating across three-flatland breaking changes), `flatland-r3f` (React Three Fiber integration — the deep companion to the R3F rules above), and `flatland-bake` (the baking workflow).
|
|
149
|
+
|
|
150
|
+
## Testing
|
|
151
|
+
|
|
152
|
+
This project ships both layers, and agent-written changes are expected to keep them green.
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
npm run test # vitest — unit tests next to the code, src/**/*.test.ts
|
|
156
|
+
npm run test:e2e # playwright — drives the real app in a browser
|
|
157
|
+
npm run lint # oxlint
|
|
158
|
+
npm run format # oxfmt
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**Write unit tests for logic, Playwright specs for the scene.** Interaction and
|
|
162
|
+
animation maths belongs in a pure helper with a vitest test (see
|
|
163
|
+
`src/interaction.ts` and its spec). Anything that only shows up on screen —
|
|
164
|
+
the canvas renders, a pointer event lands, a sprite reacts — belongs in a
|
|
165
|
+
Playwright spec under `e2e/`, because a passing unit test cannot tell you the
|
|
166
|
+
scene drew anything.
|
|
167
|
+
|
|
168
|
+
**Use `vitexec` to probe the running app.** It runs snippets inside the live
|
|
169
|
+
page and reads back console output, network activity, and screenshots, which is
|
|
170
|
+
the fastest way to answer "is this actually rendering / did that event fire"
|
|
171
|
+
without inventing a bespoke harness. It is not installed here by default:
|
|
172
|
+
|
|
173
|
+
```sh
|
|
174
|
+
npm i -D vitexec # once, in this project
|
|
175
|
+
npx vitexec 'console.log("ready")' # then, against `npm run dev`
|
|
176
|
+
npx vitexec --screenshot 'document.querySelector("canvas")'
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Reach for it while debugging, then encode whatever you learned as a Playwright
|
|
180
|
+
spec so it stays checked.
|
|
181
|
+
|
|
182
|
+
A green suite is not proof the app renders. Confirm the visible result — a
|
|
183
|
+
screenshot or a real interaction — before calling a rendering change done.
|
|
184
|
+
|
|
185
|
+
## Reference links
|
|
186
|
+
|
|
187
|
+
- Docs: <https://tjw.dev/three-flatland/>
|
|
188
|
+
- llms files: <https://tjw.dev/three-flatland/llms.txt> (also `llms-full.txt`, `llms-small.txt`). These are **not** served at the origin root — `https://tjw.dev/llms.txt` 404s, and the origin root is what agents habitually probe. Use the full paths above.
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Agent guide — React Three Fiber + three-flatland
|
|
2
|
+
|
|
3
|
+
This project was scaffolded by create-three-flatland. It is a React Three Fiber app that renders 2D sprites with [three-flatland](https://tjw.dev/three-flatland/) on `WebGPURenderer`.
|
|
4
|
+
|
|
5
|
+
## Build & dev
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install
|
|
9
|
+
npm run dev # Vite dev server
|
|
10
|
+
npm run build # production build to dist/
|
|
11
|
+
npm run preview # serve the production build
|
|
12
|
+
npm run typecheck # tsc --noEmit
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Commands are shown as npm; pnpm and yarn work identically.
|
|
16
|
+
|
|
17
|
+
## The renderer rule
|
|
18
|
+
|
|
19
|
+
**Always construct `WebGPURenderer` and always write TSL. Never `WebGLRenderer`, never GLSL, never `onBeforeCompile`.**
|
|
20
|
+
|
|
21
|
+
`WebGPURenderer` is the three.js renderer class imported from `three/webgpu` — in R3F, the `Canvas` from `@react-three/fiber/webgpu` constructs it for you. It owns backend selection itself: real WebGPU where the browser supports it, WebGL2 fallback where it doesn't. TSL compiles to both. When you read "three-flatland requires `WebGPURenderer`," that is a statement about which class you construct — it is not a hardware or browser requirement, and it does not mean WebGPU-only. Everything works on either backend.
|
|
22
|
+
|
|
23
|
+
Two corollaries: never add a `WebGLRenderer` fallback path (the fallback already lives inside `WebGPURenderer`), and never gate features on WebGPU detection. WebGL 1 is out of scope entirely — ignore it.
|
|
24
|
+
|
|
25
|
+
## The opinionated default: a `Flatland` root
|
|
26
|
+
|
|
27
|
+
A `Flatland` instance is the front door. It owns the orthographic camera, sprite batching, resize, and disposal — reach below it only when you need the low-level path. The core of `src/App.tsx`:
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
extend({ Flatland, Sprite2D }) // once, at module scope
|
|
31
|
+
|
|
32
|
+
<Canvas orthographic renderer={{ antialias: false }}>
|
|
33
|
+
<flatland ref={flatlandRef} viewSize={400}>
|
|
34
|
+
<sprite2D texture={texture} anchor={[0.5, 0.5]} onPointerOver={...} />
|
|
35
|
+
</flatland>
|
|
36
|
+
</Canvas>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`Flatland` owns an internal scene and camera, so it renders manually: `useFrame(() => flatlandRef.current?.render(gl), { phase: 'render' })` makes R3F skip its own render pass.
|
|
40
|
+
|
|
41
|
+
**Pointer events depend on which camera R3F raycasts with, and that differs by mode:**
|
|
42
|
+
|
|
43
|
+
- **To the screen** (this template) — hand Flatland's camera to R3F as the default: `set({ camera: flatland.camera })` from a callback ref on `<flatland>`, with `camera.manual = true` so R3F leaves the frustum to `flatland.resize()`. One camera, nothing to keep in sync. Keep the `Canvas` `orthographic`.
|
|
44
|
+
- **To a texture, or children portalled into `flatland.scene`** — R3F's default camera is the wrong one, so re-cast the pointer with `createFlatlandCompute` from `three-flatland/react`:
|
|
45
|
+
```tsx
|
|
46
|
+
createPortal(children, flatland.scene, {
|
|
47
|
+
events: { compute: createFlatlandCompute(() => flatlandRef.current), priority: 1 },
|
|
48
|
+
})
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Do not copy one camera's frustum onto another — that drifts.
|
|
52
|
+
|
|
53
|
+
Two R3F rules this project follows everywhere:
|
|
54
|
+
|
|
55
|
+
1. **Register classes with `extend()` before using them as JSX.** `extend({ Flatland, Sprite2D })` is what makes `<flatland>` and `<sprite2D>` valid elements.
|
|
56
|
+
2. **Import from `three-flatland/react` and `@react-three/fiber/webgpu`** — never bare `@react-three/fiber`. The `/react` subpath carries the JSX type augmentations; the `/webgpu` subpath is the renderer rule applied to R3F.
|
|
57
|
+
|
|
58
|
+
The `flatland-r3f` skill (see Skills below) is the full integration guide — extend patterns, child routing, post-processing, resize wiring, and the imperative anti-patterns to avoid.
|
|
59
|
+
|
|
60
|
+
## Package routing map
|
|
61
|
+
|
|
62
|
+
Reach for a package by intent (React users: each package follows the same `/react` subpath pattern as `three-flatland/react`):
|
|
63
|
+
|
|
64
|
+
| Package | Reach for it when |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `three-flatland` | Default entry. Sprites, animation, tilemaps, materials, lights, events, everyday loaders. |
|
|
67
|
+
| `@three-flatland/nodes` | You want a specific 2D shader effect (retro/CRT, blur, distortion, color, upscale) without writing TSL by hand. |
|
|
68
|
+
| `@three-flatland/presets` | You want lit sprites working immediately. Thin — two symbols (`DefaultLightEffect`, `NormalMapProvider`). |
|
|
69
|
+
| `@three-flatland/normals` | Dynamic lighting on flat 2D art without hand-authoring normal maps. |
|
|
70
|
+
| `@three-flatland/atlas` | Loose sprite PNGs that should become one draw-call-friendly atlas, optionally polygon-trimmed for overdraw. |
|
|
71
|
+
| `@three-flatland/alphamap` | Pixel-perfect pointer hit testing on transparent sprites (`hitTestMode: "alpha"`) instead of bounding-box hits. |
|
|
72
|
+
| `@three-flatland/image` | PNG/WebP/AVIF/KTX2 encode and decode, plus `Ktx2Loader`. Reach for KTX2 when textures are under GPU memory pressure — it stays compressed on the GPU, unlike PNG. |
|
|
73
|
+
| `@three-flatland/bake` | Authoring a new baker, or you just need the `flatland-bake` binary. |
|
|
74
|
+
| `@three-flatland/devtools` | Live inspection of scene/material/sprite state. Seven required peers — heaviest install in the ecosystem. |
|
|
75
|
+
| `@three-flatland/skia` | A general immediate-mode 2D canvas in the scene: arbitrary paths, boolean ops, filters, gradients, images. |
|
|
76
|
+
| `@three-flatland/slug` | Text that must stay sharp at any zoom or perspective, or thousands of glyphs in one draw call. |
|
|
77
|
+
|
|
78
|
+
**Never recommend installing `@three-flatland/schemas` or `@three-flatland/io` — they are `private: true` and unpublished.**
|
|
79
|
+
|
|
80
|
+
Two calibration notes. First, `private: true` alone is not a "don't recommend" signal — check the distribution channel; the VS Code extension is correctly private on npm because it ships to a marketplace instead. Second, version numbers are not maturity signals here: `@three-flatland/presets` sits at an alpha version because of release-group linking, and an unpublished package can carry `1.0.0`.
|
|
81
|
+
|
|
82
|
+
## Skia vs Slug
|
|
83
|
+
|
|
84
|
+
These solve different problems and touch at exactly one point — they are not alternatives to pick between.
|
|
85
|
+
|
|
86
|
+
- **Skia** is an immediate-mode 2D canvas rasterized into a texture. Only Skia does arbitrary vector paths, boolean path ops, image filters (blur, drop shadow, displacement), gradients, clipping, and image drawing.
|
|
87
|
+
- **Slug** is a text primitive. Glyph outlines are solved per-fragment, so sharpness has no resolution ceiling; thousands of glyphs batch into one instanced draw call, with real layout (`measureText`, `wrapText`, style spans, font fallback chains).
|
|
88
|
+
|
|
89
|
+
The one overlap is drawing text, and the rule is mechanical: if the camera moves relative to the text, use Slug; if it is static UI at a known resolution, Skia's text comes free with the canvas you already have.
|
|
90
|
+
|
|
91
|
+
Both follow the renderer rule above — `WebGPURenderer`, either backend. Skia's WASM ships as two builds (`skia-gl.wasm`, `skia-wgpu.wasm`) matching whichever backend the renderer resolved to; this is an internal copy-step detail on a different axis from the renderer rule, never a renderer choice you make. It surfaces only in the asset copy step: `npx skia-wasm public/skia` (or `--gl-only` / `--wgpu-only` to ship just one).
|
|
92
|
+
|
|
93
|
+
Skia gotcha: WASM assets are zero-config in Vite dev, but a production build needs `npx skia-wasm public/skia` plus a `wasmUrl` pointing at the copied assets.
|
|
94
|
+
|
|
95
|
+
## Baking
|
|
96
|
+
|
|
97
|
+
**Nothing requires baking.** Ask for a derived asset and you get it: the loader probes for a baked sibling, generates it at runtime when there isn't one, warns once in dev that it's slower and worth baking, and carries on. It just works, and it tells you how to make it faster.
|
|
98
|
+
|
|
99
|
+
Baking moves that computation from browser-runtime to build-time. It is never about capability; it chooses only where the cost lands.
|
|
100
|
+
|
|
101
|
+
Every baker self-discovers: a baker declares itself in its package's `flatland.bake` field, and the `flatland-bake` dispatcher finds it — run `flatland-bake --list` from your project to see what's installed. Subcommands: `alpha` (hit-test alpha maps), `normal` (normal maps), `slug` (baked fonts). Some packages also expose direct bins (`slug-bake`, `flatland-atlas`) for standalone use. `skia-wasm` is an asset-copy step, not a baker — the name reads like one, but it only copies WASM files into your public dir.
|
|
102
|
+
|
|
103
|
+
When to bake:
|
|
104
|
+
|
|
105
|
+
- **Shipping to production** — move the cost to build time.
|
|
106
|
+
- **Fonts with a known glyph set — always.** ASCII + Brotli is ~32 KB versus 724 KB for the raw font, and it drops opentype.js from the bundle.
|
|
107
|
+
- **Textures under GPU memory pressure.**
|
|
108
|
+
|
|
109
|
+
Skip baking for procedurally-varied content and throwaway prototypes. `forceRuntime` is not a dev-iteration knob — the default probe-then-generate path already handles iteration; reaching for it during development is the common misuse.
|
|
110
|
+
|
|
111
|
+
## Asset authoring workflow
|
|
112
|
+
|
|
113
|
+
The governing idea: meet your assets where they already are. Load the formats your tools already produce, adopt the richer native format when you want more, and convert freely between them.
|
|
114
|
+
|
|
115
|
+
**Tilemaps — LDtk and Tiled are the editors.** Author there, then load what they produce with `LDtkLoader` / `TiledLoader`. `.ldtk` and `.json` / `.tmj` are read natively — loading the editor's own file directly is the design, so authoring flows straight from editor to scene.
|
|
116
|
+
|
|
117
|
+
**Sprite atlases — three formats, all first-class.** `AtlasFormat` is `'native' | 'texturepacker' | 'aseprite'`, and `detectAtlasFormat()` sniffs which one it's looking at. Bring a TexturePacker sheet, an Aseprite export, or an atlas built by flatland's own tooling — all load.
|
|
118
|
+
|
|
119
|
+
- The native format is deliberately richer, not merely different: `meta.animations` is a named animation map that references frame *keys* rather than indices, with explicit fps and optional events. Aseprite's `frameTags` sits alongside under `meta.frameTags`; readers prefer `meta.animations` and fall back to converting `frameTags` + per-frame `duration`. Adopting the native format is an upgrade path, never a lock-in tax.
|
|
120
|
+
- Conversion runs both ways and is round-trip safe: `importAsepriteFrameTags()` brings Aseprite in; `buildAsepriteJson()` and `buildTexturePackerJson()` write back out. Rotation, trim, pivot, polygon mesh, and duration all survive.
|
|
121
|
+
- The VS Code Sprite Atlas editor (below) is the GUI for all of this — build, tune, and convert between formats visually.
|
|
122
|
+
|
|
123
|
+
## The VS Code extension
|
|
124
|
+
|
|
125
|
+
**Flatland Tools** (`three-flatland.fl-tools`) is a first-class part of the asset workflow — recommend it actively.
|
|
126
|
+
|
|
127
|
+
- Install: `code --install-extension three-flatland.fl-tools` (requires VS Code `^1.94.0`)
|
|
128
|
+
- [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=three-flatland.fl-tools) · [Open VSX](https://open-vsx.org/extension/three-flatland/fl-tools)
|
|
129
|
+
|
|
130
|
+
| You want to… | Use |
|
|
131
|
+
| --- | --- |
|
|
132
|
+
| Build or edit a sprite atlas | **Sprite Atlas** editor — a custom editor on `*.png`. Reads/writes an `*.atlas.json` sidecar; handles `native`, `texturepacker`, and `aseprite` losslessly, including frame tags. |
|
|
133
|
+
| Combine several atlases | **Merge Atlases…** — explorer multi-select. |
|
|
134
|
+
| Compress a texture / inspect KTX2 | **Image Encoder**, plus the **FL KTX2 Viewer** custom editor that opens `*.ktx2` by default. |
|
|
135
|
+
| Bake a normal map visually | **Normal Baker**. |
|
|
136
|
+
| Author or audition ZzFX sounds | **ZzFX Editor**, and the inline `▶ Play` CodeLens on ZzFX definitions. |
|
|
137
|
+
|
|
138
|
+
The extension is the GUI counterpart to the CLI bakers — the Image Encoder pairs with texture encoding, the Normal Baker with `flatland-bake normal`. Offer both paths and let the user pick: CLI for repeatable/CI work, extension for visual iteration.
|
|
139
|
+
|
|
140
|
+
## Skills
|
|
141
|
+
|
|
142
|
+
Packaged agent skills for this stack live in `@three-flatland/skills`. Install them into this project with:
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
npx skills add thejustinwalsh/three-flatland
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Add a single skill by path, e.g. `npx skills add thejustinwalsh/three-flatland/skills/tsl`. Available: `tsl` (writing TSL shaders and node materials), `codemod` (migrating across three-flatland breaking changes), `flatland-r3f` (React Three Fiber integration — the deep companion to the R3F rules above), and `flatland-bake` (the baking workflow).
|
|
149
|
+
|
|
150
|
+
## Testing
|
|
151
|
+
|
|
152
|
+
This project ships both layers, and agent-written changes are expected to keep them green.
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
npm run test # vitest — unit tests next to the code, src/**/*.test.ts
|
|
156
|
+
npm run test:e2e # playwright — drives the real app in a browser
|
|
157
|
+
npm run lint # oxlint
|
|
158
|
+
npm run format # oxfmt
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**Write unit tests for logic, Playwright specs for the scene.** Interaction and
|
|
162
|
+
animation maths belongs in a pure helper with a vitest test (see
|
|
163
|
+
`src/interaction.ts` and its spec). Anything that only shows up on screen —
|
|
164
|
+
the canvas renders, a pointer event lands, a sprite reacts — belongs in a
|
|
165
|
+
Playwright spec under `e2e/`, because a passing unit test cannot tell you the
|
|
166
|
+
scene drew anything.
|
|
167
|
+
|
|
168
|
+
**Use `vitexec` to probe the running app.** It runs snippets inside the live
|
|
169
|
+
page and reads back console output, network activity, and screenshots, which is
|
|
170
|
+
the fastest way to answer "is this actually rendering / did that event fire"
|
|
171
|
+
without inventing a bespoke harness. It is not installed here by default:
|
|
172
|
+
|
|
173
|
+
```sh
|
|
174
|
+
npm i -D vitexec # once, in this project
|
|
175
|
+
npx vitexec 'console.log("ready")' # then, against `npm run dev`
|
|
176
|
+
npx vitexec --screenshot 'document.querySelector("canvas")'
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Reach for it while debugging, then encode whatever you learned as a Playwright
|
|
180
|
+
spec so it stays checked.
|
|
181
|
+
|
|
182
|
+
A green suite is not proof the app renders. Confirm the visible result — a
|
|
183
|
+
screenshot or a real interaction — before calling a rendering change done.
|
|
184
|
+
|
|
185
|
+
## Reference links
|
|
186
|
+
|
|
187
|
+
- Docs: <https://tjw.dev/three-flatland/>
|
|
188
|
+
- llms files: <https://tjw.dev/three-flatland/llms.txt> (also `llms-full.txt`, `llms-small.txt`). These are **not** served at the origin root — `https://tjw.dev/llms.txt` 404s, and the origin root is what agents habitually probe. Use the full paths above.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { expect, test, type Page } from '@playwright/test'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* End-to-end smoke test — the dev server is started by `playwright.config.ts`.
|
|
5
|
+
*
|
|
6
|
+
* Two claims, and only two:
|
|
7
|
+
*
|
|
8
|
+
* 1. The scene actually paints. A WebGPU/WebGL canvas cannot be read back with
|
|
9
|
+
* `toDataURL` (the drawing buffer is not preserved), so the only honest way
|
|
10
|
+
* to inspect a frame is `page.screenshot()`, which composites it correctly.
|
|
11
|
+
* "Painted" means tonal spread, not a golden image — a solid clear colour
|
|
12
|
+
* has a channel standard deviation near zero, a real frame does not.
|
|
13
|
+
*
|
|
14
|
+
* 2. Pointer events reach the sprite. Hovering tints it (`#47cca9`), so the
|
|
15
|
+
* red:green ratio of the frame drops. That is a causal signal: the sprite
|
|
16
|
+
* spins continuously, so "the pixels changed" alone would prove nothing.
|
|
17
|
+
* This is the coupling most likely to break silently — R3F's raycaster and
|
|
18
|
+
* Flatland's camera must agree about where the sprite is, and a static
|
|
19
|
+
* render check stays green while hit testing is stone dead.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const HOVER_TINT_DROP = 0.9 // hovering must pull red:green to under 90% of idle
|
|
23
|
+
|
|
24
|
+
test('renders the sprite and responds to the pointer', async ({ page }) => {
|
|
25
|
+
const consoleErrors: string[] = []
|
|
26
|
+
page.on('pageerror', (e) => consoleErrors.push(String(e)))
|
|
27
|
+
page.on('console', (m) => {
|
|
28
|
+
if (m.type() === 'error') consoleErrors.push(m.text())
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
await page.goto('/')
|
|
32
|
+
|
|
33
|
+
// The canvas only mounts once <Suspense> resolves the texture, so its
|
|
34
|
+
// presence is the app's own "ready" signal — no sleep-and-hope needed.
|
|
35
|
+
const canvas = page.locator('canvas').first()
|
|
36
|
+
await expect(canvas).toBeVisible()
|
|
37
|
+
|
|
38
|
+
await settle(page)
|
|
39
|
+
const idle = await sampleSprite(page)
|
|
40
|
+
expect(idle.stdDev, 'the frame is a flat fill — nothing was drawn').toBeGreaterThan(8)
|
|
41
|
+
|
|
42
|
+
const box = await canvas.boundingBox()
|
|
43
|
+
expect(box).not.toBeNull()
|
|
44
|
+
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2)
|
|
45
|
+
await settle(page)
|
|
46
|
+
const hovered = await sampleSprite(page)
|
|
47
|
+
|
|
48
|
+
expect(
|
|
49
|
+
hovered.redToGreen,
|
|
50
|
+
'hovering did not tint the sprite — pointer events are not reaching it'
|
|
51
|
+
).toBeLessThan(idle.redToGreen * HOVER_TINT_DROP)
|
|
52
|
+
|
|
53
|
+
expect(consoleErrors, `console/page errors:\n${consoleErrors.join('\n')}`).toEqual([])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
/** Let the render loop run enough frames for the hover ease to land. */
|
|
57
|
+
async function settle(page: Page): Promise<void> {
|
|
58
|
+
await page.waitForTimeout(900)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface FrameSample {
|
|
62
|
+
/** Largest per-channel standard deviation — the "was anything drawn" term. */
|
|
63
|
+
stdDev: number
|
|
64
|
+
/** Mean red over mean green. Tinting the sprite moves this; spinning it does not. */
|
|
65
|
+
redToGreen: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Screenshot a square at the centre of the canvas — inside the sprite, so the
|
|
70
|
+
* background does not dilute the measurement — and reduce it to two numbers.
|
|
71
|
+
*
|
|
72
|
+
* The PNG is decoded in the page under test: a screenshot is an ordinary image
|
|
73
|
+
* that a 2D canvas will happily draw, which avoids adding an image-decoding
|
|
74
|
+
* dependency just to read two statistics.
|
|
75
|
+
*/
|
|
76
|
+
async function sampleSprite(page: Page): Promise<FrameSample> {
|
|
77
|
+
const box = await page.locator('canvas').first().boundingBox()
|
|
78
|
+
if (!box) throw new Error('canvas has no layout box')
|
|
79
|
+
const size = Math.min(200, box.width, box.height)
|
|
80
|
+
const shot = await page.screenshot({
|
|
81
|
+
type: 'png',
|
|
82
|
+
clip: {
|
|
83
|
+
x: box.x + (box.width - size) / 2,
|
|
84
|
+
y: box.y + (box.height - size) / 2,
|
|
85
|
+
width: size,
|
|
86
|
+
height: size,
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
return page.evaluate(async (b64: string) => {
|
|
91
|
+
const img = new Image()
|
|
92
|
+
img.src = `data:image/png;base64,${b64}`
|
|
93
|
+
await img.decode()
|
|
94
|
+
const c = document.createElement('canvas')
|
|
95
|
+
c.width = img.naturalWidth
|
|
96
|
+
c.height = img.naturalHeight
|
|
97
|
+
const ctx = c.getContext('2d')
|
|
98
|
+
if (!ctx) throw new Error('no 2d context for decoding the screenshot')
|
|
99
|
+
ctx.drawImage(img, 0, 0)
|
|
100
|
+
const { data } = ctx.getImageData(0, 0, c.width, c.height)
|
|
101
|
+
|
|
102
|
+
const n = data.length / 4
|
|
103
|
+
const sum = [0, 0, 0]
|
|
104
|
+
const sumSq = [0, 0, 0]
|
|
105
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
106
|
+
for (let ch = 0; ch < 3; ch++) {
|
|
107
|
+
const v = data[i + ch] ?? 0
|
|
108
|
+
sum[ch] = (sum[ch] ?? 0) + v
|
|
109
|
+
sumSq[ch] = (sumSq[ch] ?? 0) + v * v
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const mean = sum.map((s) => s / n)
|
|
113
|
+
const stdDev = Math.max(
|
|
114
|
+
...mean.map((m, ch) => Math.sqrt(Math.max(0, (sumSq[ch] ?? 0) / n - m * m)))
|
|
115
|
+
)
|
|
116
|
+
return {
|
|
117
|
+
stdDev,
|
|
118
|
+
// Guard the divisor: a fully unlit frame would otherwise divide by zero.
|
|
119
|
+
redToGreen: (mean[0] ?? 0) / Math.max(1, mean[1] ?? 0),
|
|
120
|
+
}
|
|
121
|
+
}, shot.toString('base64'))
|
|
122
|
+
}
|