Threejs Capacitor iOS
chongdashu/vibejam-starter-pack
Build and ship Three.js apps on Capacitor iOS with Vite and Swift Package Manager: GLTF loading, assetsindex animation UI, OrbitControls mouse/touch mappings, and iOS sync/run troubleshooting.
Optimise a three.js or raw WebGL scene for phones, 120 Hz panels and low-end devices — device tiering, prewarm-everything-under-the-loader so nothing compiles mid-scroll, in-view-only render loops…
$ npx skills add textura-agency/next16-claude-starter --skill optimize-3d-scene -a claude-codeProject install by default; add -g for ~/.claude/skills/.
$ gh skill install textura-agency/next16-claude-starter optimize-3d-scene --agent claude-codeProject scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).
$ git clone --depth 1 https://github.com/textura-agency/next16-claude-starter.git skills-src && mkdir -p .claude/skills && cp -r skills-src/.claude/skills/optimize-3d-scene .claude/skills/optimize-3d-scene && rm -rf skills-srcUse ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.
Claude Code skills documentation · loads skills from .claude/skills/
Install the "optimize-3d-scene" agent skill from https://github.com/textura-agency/next16-claude-starter/tree/main/.claude/skills/optimize-3d-scene into .claude/skills/optimize-3d-scene/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "optimize-3d-scene", then confirm the skill loads.Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$skill-installer install https://github.com/textura-agency/next16-claude-starter/tree/main/.claude/skills/optimize-3d-sceneType this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
$ npx skills add textura-agency/next16-claude-starter --skill optimize-3d-scene -a codexProject install goes to .agents/skills/; add -g for ~/.codex/skills/.
$ gh skill install textura-agency/next16-claude-starter optimize-3d-scene --agent codexProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/textura-agency/next16-claude-starter.git skills-src && mkdir -p .agents/skills && cp -r skills-src/.claude/skills/optimize-3d-scene .agents/skills/optimize-3d-scene && rm -rf skills-srcUse ~/.agents/skills/ instead of .agents/skills for a personal install.
Codex skills documentation · loads skills from .agents/skills/
Install the "optimize-3d-scene" agent skill from https://github.com/textura-agency/next16-claude-starter/tree/main/.claude/skills/optimize-3d-scene into .agents/skills/optimize-3d-scene/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "optimize-3d-scene", then confirm the skill loads.Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add textura-agency/next16-claude-starter --skill optimize-3d-scene -a cursorProject install goes to .agents/skills/; add -g for ~/.cursor/skills/.
$ gh skill install textura-agency/next16-claude-starter optimize-3d-scene --agent cursorProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/textura-agency/next16-claude-starter.git skills-src && mkdir -p .cursor/skills && cp -r skills-src/.claude/skills/optimize-3d-scene .cursor/skills/optimize-3d-scene && rm -rf skills-srcUse ~/.cursor/skills/ instead of .cursor/skills for a personal install.
Cursor skills documentation · loads skills from .cursor/skills/, .agents/skills/, .claude/skills/, .codex/skills/
Install the "optimize-3d-scene" agent skill from https://github.com/textura-agency/next16-claude-starter/tree/main/.claude/skills/optimize-3d-scene into .cursor/skills/optimize-3d-scene/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "optimize-3d-scene", then confirm the skill loads.Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gemini skills install https://github.com/textura-agency/next16-claude-starter.git --path .claude/skills/optimize-3d-scene--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
$ npx skills add textura-agency/next16-claude-starter --skill optimize-3d-scene -a gemini-cliProject install goes to .agents/skills/; add -g for ~/.gemini/skills/.
$ gh skill install textura-agency/next16-claude-starter optimize-3d-scene --agent gemini-cliProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/textura-agency/next16-claude-starter.git skills-src && mkdir -p .gemini/skills && cp -r skills-src/.claude/skills/optimize-3d-scene .gemini/skills/optimize-3d-scene && rm -rf skills-srcUse ~/.gemini/skills/ instead of .gemini/skills for a personal install, then run /skills reload.
Gemini CLI skills documentation · loads skills from .gemini/skills/, .agents/skills/
Install the "optimize-3d-scene" agent skill from https://github.com/textura-agency/next16-claude-starter/tree/main/.claude/skills/optimize-3d-scene into .gemini/skills/optimize-3d-scene/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "optimize-3d-scene", then confirm the skill loads.Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gh skill install textura-agency/next16-claude-starter optimize-3d-sceneInstalls for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
$ npx skills add textura-agency/next16-claude-starter --skill optimize-3d-scene -a github-copilotProject install goes to .agents/skills/; add -g for ~/.copilot/skills/.
$ git clone --depth 1 https://github.com/textura-agency/next16-claude-starter.git skills-src && mkdir -p .github/skills && cp -r skills-src/.claude/skills/optimize-3d-scene .github/skills/optimize-3d-scene && rm -rf skills-srcUse ~/.copilot/skills/ instead of .github/skills for a personal install. Commit .github/skills so cloud agent and code review can use it.
GitHub Copilot skills documentation · loads skills from .github/skills/, .claude/skills/, .agents/skills/
Install the "optimize-3d-scene" agent skill from https://github.com/textura-agency/next16-claude-starter/tree/main/.claude/skills/optimize-3d-scene into .github/skills/optimize-3d-scene/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "optimize-3d-scene", then confirm the skill loads.GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add textura-agency/next16-claude-starter --skill optimize-3d-scene -a opencodeOpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
$ gh skill install textura-agency/next16-claude-starter optimize-3d-scene --agent opencodeProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/textura-agency/next16-claude-starter.git skills-src && mkdir -p .opencode/skills && cp -r skills-src/.claude/skills/optimize-3d-scene .opencode/skills/optimize-3d-scene && rm -rf skills-srcUse ~/.config/opencode/skills/ instead of .opencode/skills for a personal install.
OpenCode skills documentation · loads skills from .opencode/skills/, .claude/skills/, .agents/skills/
Install the "optimize-3d-scene" agent skill from https://github.com/textura-agency/next16-claude-starter/tree/main/.claude/skills/optimize-3d-scene into .opencode/skills/optimize-3d-scene/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "optimize-3d-scene", then confirm the skill loads.OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
optimize-3d-sceneOptimise a three.js or raw WebGL scene for phones, 120 Hz panels and low-end devices — device tiering, prewarm-everything-under-the-loader so nothing compiles mid-scroll, in-view-only render loops…
Optimize 3D Scene is an agent skill from textura-agency/next16-claude-starter. Optimise a three.js or raw WebGL scene for phones, 120 Hz panels and low-end devices — device tiering, prewarm-everything-under-the-loader so nothing compiles mid-scroll, in-view-only render loops, DPR and particle/bloom budgets (never a fixed phone frame cap), the 60 fps desktop cap, OffscreenCanvas worker scenes, GPU-side scroll transforms, compressed models, iOS toolbar/context-loss survival, and keeping the scene off the robot form. Use when the user says "optimise the 3D", "the scene lags on mobile", "low…
Its SKILL.md is about 9.5k tokens, which your agent loads only when the skill is triggered. The skill folder holds 2 other files, including reference files (for example `references/patterns.md`).
It sits in Game Development, covering 3D graphics and WebGL. It works with Three.js, iOS and Obsidian. The repository describes itself as: AI-first Next.js 16 starter for animation-heavy sites, wired with an Obsidian vault & Claude Code hooks. The licence is Unlicense.
12 steps, taken from the step headings in SKILL.md.
Read from SKILL.md and the folder at commit 1170942. It shows what the files ask for, not the result of running them.
Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.
From allowed-tools in the SKILL.md frontmatter.
Shell commands in SKILL.md call:
nodeyarnnpxgitFrom the folder's file list and the shell code blocks in SKILL.md.
No URLs in SKILL.md. Its commands use yarn, npx and git, which can reach the network depending on how they are called.
From URLs in SKILL.md, links to its own repository left out.
Names no API keys, tokens, secrets or passwords.
From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
Optimize 3D Scene loads about 9.5k tokens when it runs, and up to ~20k if it reads all its reference files. Until then it costs about 173 tokens; SKILL.md has 5,263 words of instructions outside code blocks.
Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.
The automated check found no risky patterns in SKILL.md.
Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.
The full file from textura-agency/next16-claude-starter at commit 1170942, republished under its Unlicense licence (© textura-agency). 5,263 words, ~9,464 tokens.
.claude/skills/optimize-3d-scene/SKILL.md (or your agent's skills folder). This skill also uses 1 other file; get the full folder from GitHub.Every page that carries a three.js scene pays the same tax: a phone renders the same fragments as a workstation, the first frame after a shader appears compiles mid-scroll, and the render loop keeps running behind three sections of copy nobody is looking at. This skill fixes those in a fixed order — cheapest and highest-impact first. It was rewritten from optimisation passes on ~50 production sites; each rule says whether it is a rule (held on ≥ 3 sites) or observed (1–2 — try it, measure it, don't assume it).
Every step applies to a raw WebGL scene as much as a three.js one; only §0's
measurement primitives differ (three.js hands you renderer.info, a raw scene
you instrument yourself — which you must do before you can begin).
Before you fix anything, read obsidian/knowledge/fix-catalog.md and
obsidian/knowledge/pitfalls.md — the answer, and the trap next to it, may
already be written down. iOS / real-phone behaviour (toolbar resizes, context
loss, 120 Hz speed, gyroscope) is owned by mobile-device-qa; this skill links
to it where they meet.
Modules this starter already ships — use them, don't write a sixth copy:
| need | module |
|---|---|
one clock in seconds, dt-scaled easing (createFrameClock, perFrame, damp) | src/lib/scene/per-frame.ts |
| a scene box that ignores the iOS URL bar | src/utils/stable-viewport.ts + src/components/common/scene-viewport.tsx |
WebGL context-loss recovery (keepSceneAlive, watchContext, releaseContext) | src/lib/scene/webgl-context.ts |
| gyroscope tilt for a hero model on phones | src/lib/scene/device-tilt.ts |
| robot / reduced-motion switch | src/hooks/use-motion-off.ts, src/utils/bot-ua.ts, src/proxy.ts |
| device tier, ticker, visibility gate, 60 fps desktop draw gate, scroll lerp, disposal | references/patterns.md (copy-paste forms) |
Architecture notes for these: obsidian/frontend/webgl-scenes.md,
obsidian/frontend/robot-form.md.
Never optimise blind. Establish the baseline:
# what's actually in the scene
grep -rn "setPixelRatio\|requestAnimationFrame\|new THREE\..*Light\|UnrealBloom\|Points\|InstancedMesh\|transferControlToOffscreen\|1000 / 30\|frameloop" src/ --include=*.ts --include=*.tsx --include=*.jsThen in the running page's console:
renderer.info.render // { calls, triangles, points } — per frame
renderer.info.programs.length // shader programs; each one is a compile stall if it appears late
renderer.info.memory // { geometries, textures }Raw WebGL (no three.js). renderer.info only exists on
THREE.WebGLRenderer. A hand-written scene has no equivalent — hook the context
before app code runs and count it yourself, or you cannot start:
// page.evaluateOnNewDocument — counts passes, vertices, and *when* programs link
const gc = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (kind, attrs) {
const ctx = gc.call(this, kind, attrs);
if (ctx && kind === "webgl") {
window.__gl = ctx;
window.__p = { draws: 0, verts: 0, frames: 0, links: [], attrs };
const draw = ctx.drawArrays.bind(ctx);
ctx.drawArrays = (m, f, c) => { window.__p.draws++; window.__p.verts += c; return draw(m, f, c); };
const clear = ctx.clear.bind(ctx); // one clear = one frame
ctx.clear = (m) => { window.__p.frames++; return clear(m); };
const link = ctx.linkProgram.bind(ctx); // §3/§14: these must all precede the loader handoff
ctx.linkProgram = (p) => { window.__p.links.push(Math.round(performance.now())); return link(p); };
}
return ctx;
};draws/verts replace info.render, links.length replaces
programs.length (and links timestamps are what §3 is actually measured
against), gl.drawingBufferWidth/Height is the §6 check, and the captured
attrs is the §7 renderer-flags check. Full harness in references/patterns.md.
The standing instruments (tools/qa/, see its README; yarn qa:setup
once — they install into a cache outside the project). All take --url of a
running yarn build && yarn start:
| question | tool |
|---|---|
| how many frames does the scene draw (vs the page's rAF)? | node tools/qa/fps-probe.mjs --url … |
| does the scroll drop frames, where, and why? | node tools/qa/scroll-test.mjs --url … |
| which code is the long task (source-mapped)? | node tools/qa/profile.mjs --url … [--scroll-to "<sel>"] |
| does an iOS toolbar height step clear / re-size the canvas? | node tools/qa/ios-toolbar-probe.mjs --url … |
| does the hero draw again after a context loss off screen? | node tools/qa/context-loss-probe.mjs --url … |
| Lighthouse, people and robot | node tools/qa/lighthouse.mjs --url … (--as-bot for the robot form) |
| a still of the running canvas (robot poster, phone stills) | node tools/qa/capture-still.mjs --url … |
yarn build && yarn start — and kill
the old server by port before rebuilding (next start runs as
next-server; pkill -f "next start" misses it), or it serves a stale
manifest and you debug 404s that aren't yours. Gate the server start on the
build's exit code — a failed build happily serves the previous .next and
your A/B compares the old build with itself.tools/qa tools set a
plain Chrome/Safari UA; your own scripts must too.waitUntil: "load" plus a fixed settle. networkidle0 never fires
against next start.fps-probe runs headed for that reason.git worktree of the
base branch, never by copying .next/ or node_modules/.Write the before/after numbers down. A change you cannot measure is a change you cannot defend, and every item below costs something in look.
A crawler or Lighthouse's robot run gets no scene at all — not a hidden
canvas, not a lazily-idle module. The starter does this in the proxy, not
in the page: src/proxy.ts rewrites a bot UA (src/utils/bot-ua.ts, which
includes the AI crawlers) to the prerendered /robot-view route, where the
view renders a still in place of the scene. Read
optimize-load/references/robot-path.md before touching it.
import() (or
dynamic(..., { ssr: false })), so three lands in its own chunk and the
robot form never fetches it. Never re-export it from a barrel next to
lighter siblings — importing any sibling from the barrel pulls three.js into
its chunk (observed).await isBot() in a page or layout. It reads headers() and makes
the route dynamic for every visitor (no CDN cache). The UA belongs in the
proxy (rule — the starter shipped that defect into most of the sites it
seeded).useProgress). Import drei only inside the
lazily loaded scene.isRobotView() (the server's
<meta name="x-robot-view">) once the document is parsed, and skip its model
preloads on the robot form.The still. It exists for (a) crawler and share-card screenshots, which
otherwise capture an empty box, and (b) the no-WebGL / context-lost fallback.
node tools/qa/capture-still.mjs --url … --save public/assets/scene-still
captures the running canvas with text and chrome hidden. Its wait must cover the
scene's own entrance, not just the loader. Look at it before wiring it. If
the camera fits to the tighter axis, one landscape still re-crops the subject on
portrait — export desktop and phone crops and pick with <picture>.
One module decides what "mobile" means. Everything — DPR, particle counts,
bloom, whether the pointer is even listened to — reads from it, so the values
can never drift apart. Read it at construction and hold it in a mutable slot:
never recompute it per frame, and never rebuild buffers on every resize event.
mobile = innerWidth < 768 || matchMedia("(hover: none) and (pointer: coarse)").
The coarse-pointer clause is what catches tablets and large phones.
"Once" does not mean "never again". The tier changes mid-session in real
cases: a window dragged across a breakpoint, DevTools device mode switched on or
off (which flips DPR, pointer and hover at once — the way most reviewers check a
phone layout), a tablet rotated. A scene that read the tier into a const kept
the 390-wide buffer, the parked pointer and the hidden desktop passes on a
2160-wide viewport and drew skewed until reload. So:
retune(tier): re-apply DPR (renderer and composer, §6),
per-tier visibility flags, bind or unbind the pointer listener (§11).material.transparent (§3.2), so no program is
compiled. Allocate the largest tier's particle buffer at construction and vary
the count with geometry.setDrawRange.useIsMobile that
reads width 0 during SSR makes phones hydrate the desktop composition, then
rebuild the mobile one (observed: ~0.25 s of the longest task). Switch layout
with CSS breakpoints; where JS must decide, render both and hide one with CSS.Also expose, from the same module:
prefersReducedMotion() — an accessibility promise, honoured on every tier.isEnergySaver() — navigator.connection.saveData or deviceMemory <= 2.sceneShouldSettle() — reduced motion, or a constrained phone: play the
entrance, then stop advancing time. Do not stop drawing a visible canvas
and trust the last frame — iOS drops it after a toolbar resize or a
re-composite and the scene "disappears" (§4). A settled scene still redraws on
resize, visibility return and context restore.This is the rule that kills micro-freezes. After the loader hands off, the frame loop must allocate nothing, compile nothing and upload nothing. A stall on scroll — or a frozen loader — is always one of five things:
await renderer.compileAsync(scene, camera)
while the loader is still on screen. Raw WebGL: never read
COMPILE_STATUS / LINK_STATUS in the task that compiled — poll
KHR_parallel_shader_compile's COMPLETION_STATUS_KHR once a frame first
(observed: a gradient's mount 1,084 → 16 ms at 4× CPU). three.js reads every
program's status and uniforms right after linking, so the main thread waits
on the GPU compile (130–840 ms per scene build on a phone): set
renderer.debug.checkShaderErrors = false in production.USE_INSTANCING, transparent, a different light count, fog, tone
mapping, the render target's colour space. Never flip a define, a light
count, material.transparent or blending at runtime. Set the final variant
at construction and drive change through uniforms only.render that samples a texture
uploads it. renderer.initTexture(tex) for every texture during the loader.
Big attribute buffers: upload one per frame, not all on the first draw
(observed: seven ~17.7 MB grass buffers → one 84 ms mobile frame, gone when
queued one per rAF).EffectComposer /
WebGLRenderTarget allocates and compiles on its first use.new Worker(new URL("./x.worker.ts", import.meta.url), { type: "module" })
(Turbopack bundles it), transfer buffers both ways, keep an inline fallback.
Measured: 140k-point surface sampling in a useMemo, 2.6 s at 4× → worker:
mobile TBT 4.3 s → 0.2 s, perf 52 → 85. Hold the reveal until the worker
answers. Keep the new Worker(...) launcher in its own file — in the
module the worker itself imports, next build hangs (observed). Build point
clouds straight into Float32Arrays, not via SphereGeometry and a copy
(observed: 7× faster, start-up task 290 → 180 ms).How to prewarm so it actually covers the first frame (rule — the same family on 4+ sites):
requestAnimationFrame: textures → scene shaders → extra materials → one
frame each for every pass the first real frame uses (scene, bloom chain,
composite). setTimeout(0) chains do not yield a frame — they ran
back-to-back and the "split" work landed in one frame anyway (measured: first
real frame 43–56 → 4–5 ms with per-rAF steps).Verify: renderer.info.programs.length must not grow after the handoff.
§1 and §3 pull against each other — check the gap, every time.
Code-splitting the scene means it cannot compile until after hydration; on a
slow connection that lands after the loader hands off. Measure it (linkProgram
timestamps vs the handoff) and close it: start the scene's data fetch at first
paint (not a <head> preload — that bills the bytes to the LCP, see
optimize-load), and gate the loader on scene-ready rather than a fixed
duration.
A scene far below the fold has no loader to hide behind — its construction
lands inside the visitor's scroll. Mount it on idle after load, not on
proximity (observed). Don't move the build (or even the module's evaluation)
to well after load — that cured one site's scroll and cost mobile Lighthouse
8–9 points, because it fell in Lighthouse's window. Build one idle task per
step at the mount — renderer; each environment material pre-compiled with
compileAsync under PMREM's own state (NoToneMapping, a render target set —
both are in the program key, and so is the light count);
fromScene(new Scene(), sigma) once to build PMREM's private programs; the real
fromScene; the model's compileAsync; first render. Measured: mobile worst
frame 159 → 52 ms, Lighthouse mobile 71 → 75. Find the heavy step with
tools/qa/profile.mjs --scroll-to "<selector>". Never defer preloads to
"after the entrance" either — their decode then lands on the first scroll.
[!warning] The
as="fetch"preload credentials trap Anas="fetch"preload is only reused when its credentials mode matches thefetch()exactly.crossorigin="anonymous"+credentials: "omit"does not match; both silently download the asset twice. The pair that dedupes iscrossorigin="use-credentials"+credentials: "include". Count network requests (page.on("request")), notfetchcalls.
Gate the loop on all three:
document.hidden — a background tab paints nothing.IntersectionObserver with a
rootMargin of about one viewport so it is already warm when it arrives.Subscribe to one app-wide rAF ticker rather than a loop per scene. Several forever-rAF scenes on one page were the documented cause of scroll jank on more than one site — and a duplicate loop (StrictMode double mount, a loop started on both mount and resize) also makes every per-frame increment run twice (§5).
The other half — never stop drawing a visible canvas (observed, iOS, on a real phone): a "freeze the hero after 10 % scroll" optimisation relied on the browser keeping the last frame; iOS drops it after a toolbar resize and the stopped scene never repainted — the hero "disappeared". Pause only when the canvas is off screen.
Survive a lost context. iOS drops WebGL contexts under memory pressure
(decoding big stills while the hero is off screen was enough) and nothing
rebuilds them — the scene is a permanent blank. Use keepSceneAlive from
src/lib/scene/webgl-context.ts: preventDefault the webglcontextlost;
when the scene nears the viewport, the tab returns or pageshow fires, check
gl.isContextLost() and rebuild on a fresh context at rest (no intro
replay; ≥ 1 s between rebuilds, 3 retries); forceContextLoss() on every
teardown so a rebuild never holds two contexts; don't allocate desktop-only
render targets on phones. Prove it with tools/qa/context-loss-probe.mjs
(FAIL → PASS on the site that taught it). Details: mobile-device-qa.
r3f: <Canvas frameloop="never"> re-applies the prop on every re-render —
with a later setFrameloop the scene went black after the second resize
(observed). Own the loop in one place.
[!warning] Clamping DPR on a
THREE.Pointsscene changes its lookgl_PointSizeis in framebuffer pixels: at a lower DPR every point draws bigger on screen. PassuPixelScale = renderDpr / originalDprand multiply the size (and any minimum) by it (observed: 2 → 1.5 with the scale, pixel diff vs live = the scene's motion only).
This section replaced an older rule ("30 fps on phones") after real-phone reviews. Rule (5+ sites): no fixed phone frame cap.
t - last <= 1000/30 throttle on a 60 Hz loop draws every 3rd frame
(20 fps); on a 120 Hz iPhone every 5th (26 fps measured). A reviewer
on an iPhone: "feels low FPS, looks really bad". Lifting the cap took
scenes 26 → 120 fps at rest with the phone scroll test unchanged or
better (1.19 → 0.58 % dropped on one site; ideal before and after on
others).tools/qa/fps-probe.mjs: read the
scene's draws/s against the page's raf/s; a budget throttle shows as
draws ≈ raf/3 at 60 Hz.frame % 2) — never a budget throttle — and say so in the report.Desktop: cap the draw at 60 fps on fast panels (rule, 6 sites). On a 120 Hz panel a GPU-bound scene + post chain redraws every 8.3 ms tick; the scroll test read 2–31 % dropped with the main thread idle. An accumulator that draws at most every frame of 60 Hz took those to 0–1.9 %. Details that matter:
references/patterns.md §16 (frameBudgetMs in §1 returns
12.5 on desktop, 0 elsewhere).Speed must not follow the refresh rate. Per-frame increments not scaled by
delta time run 2× at 120 Hz and 4× with a duplicate loop (observed: a phone
scene "2–4× too fast and shaking"). Every += k becomes += k * dt * 60; every
lerp(a, b, k) becomes lerp(a, b, perFrame(k, dt)); dt clamped
(MAX_FRAME_DT). One clock, in seconds for three.js (createFrameClock). Simulate 120 Hz headless by replacing
requestAnimationFrame with an 8 ms timer and compare pixel change per 1/60 s
against 60 Hz (mobile-device-qa §120 Hz).
phone → 1.0 … 1.5 (soft point clouds may sit at 1; hard edges, a model, text → 1.5 + MSAA)
tablet → ≤ 1.25 … 1.5
desktop → ≤ 1.5 (cap even when the panel is 2×)A 3× phone renders 9× the fragments of a 1× screen, for no perceptible gain on a point cloud or a soft shader — so clamp. But below 1 reads as noise: real-phone review flagged two phone heroes as "noisy" — one at DPR 0.75, one at DPR 1 without MSAA (stair-stepped model edges, smeared print). DPR 1.5 + MSAA, no frame cap, fixed both with performance held (production mobile 94). Look at a crop at DPR 3 before and after any DPR change. Desktop ≤ 1.5 held on several sites (rule); one desktop scene was capped at 1.5 at the client's request.
EffectComposer owns its own render targets. Clamp the renderer and leave
the composer at raw devicePixelRatio and you throw the saving away on the
post pass. Set both from the same function.
Never let a canvas buffer follow a scroll-scrubbed box (observed): a card
whose width/height scroll scrubs made R3F reallocate the buffer every frame
(100–235 ms setSize frames; a debounce didn't help). Hold the buffer at the
box's final size and stretch it over the live box with CSS.
In order of what actually costs:
drawArrays (references/patterns.md §8) — a spatially sorted buffer loses a
region, not a sample.gl_Position = vec4(2.0, 2.0, 2.0, 1.0)) — observed 7.8 → 4.6 ms GPU per
frame, identical image.enabled = strength > 0.001 — and warm it once, §3).
Scale bloom by viewport height too.CubeCamera.update() is six more renders — never
every frame for something that moves slowly (observed: every 3rd frame on
desktop, every 6th on a phone).gl_PointSize;
depthWrite: false on transparents.alpha: false when the canvas is opaque,
stencil: false, depth: false when nothing depth-tests,
powerPreference: "high-performance" on desktop only. MSAA: off for soft
point fields on phones, on for a model with hard edges (§6).filter: blur() over a full-screen scene janked desktop scroll with the main
thread idle. See optimize-performance (blur once per line).Every real-time light multiplies the fragment cost of every lit material, and changing the light count recompiles every program.
RoomEnvironment replaces three or four fills and looks better.onBeforeCompile costs a
few ALU ops and reads as a light.Any per-object transform that scroll drives should be a uniform feeding the
vertex shader, not a JS loop mutating position/rotation per frame.
aOffset/aRandom attributes +
a uProgress uniform. Scroll then costs one uniform write per frame.frustumCulled = false on anything whose positions the shader computes.Group (one matrix), never the children.Vector3/Matrix4 allocation — module-scope scratch objects.window.scrollY once per frame inside the ticker. Never in a scroll
handler that also writes styles.<html> per frame for the scene's sake (a
pointer --x/--y): once it changed on every scrolled frame it restyled 571
elements per frame and dropped 25–35 % of a section's frames (rule: write
only on change, on the smallest element, never pointer vars during touch
scroll).On mobile the OS owns momentum scrolling, so window.scrollY arrives in
discrete steps and every derived value jitters. Low-pass it once, upstream:
smoothed += (raw - smoothed) * kFrame; // kFrame = 1 - Math.pow(1 - k, dt * 60)k 0.2–0.3 on mobile, ≈ 0.3 on desktop (Lenis eases the wheel, but steppy
wheel input still shows as camera jitter).|raw - smoothed| > 1.5vh, assign
directly.On a touch device pointer effects are dead weight or actively wrong:
mousemove on the mobile tier at all. Gate every pointer effect
on "has the pointer ever moved" — an unmoved cursor resolves to NDC (0,0),
dead centre, so a repulsion field punches a hole in the middle of the scene on
every phone.retune() (§2), not a one-shot check.intersectObject() on a dense
mesh per pointermove held one phone's scroll at 1–3 % dropped frames on its
own; the analytic shape (ray.intersectSphere, intersectPlane) or a
low-poly proxy: 0 % (observed).src/lib/scene/device-tilt.ts (startTilt() on mount, readTilt(dt) per
frame, stopTilt() on unmount; iOS permission on a tap, idle sway, off for
robots and reduced motion). It is a taste call: it was kept on most
sites and removed on some. Ask. Wiring and proof:
mobile-device-qa §gyroscope.prune). Observed: 4.67 MB → 61 KB, vertex/index count and
a position hash identical./draco/), not on a CDN.npx @gltf-transform/cli inspect in.glb
— one 8 MB GLB carried eight uncompressed PNGs).
npx @gltf-transform/cli webp in.glb out.glb --quality 85
(EXT_texture_webp, read by three's GLTFLoader with no extra loader):
observed 8.55 → 1.67 MB, desktop LCP 7.3 → 1.9 s. It saves bytes, not VRAM;
KTX2 (--texture-compress ktx2, needs KTX-Software's toktx) is the step for
GPU pressure.maxTextureSize or the scene's cap is 4096 (2048 on phones) is resampled on
the main thread before upload. Build per-tier models
(gltf-transform resize → webp → draco) and pick by tier (observed, with a
post-paint start: mobile 51 → 87).anisotropy = 1 on soft scenes on mobile (raise to 4 for a printed/textured
model the eye reads), generateMipmaps for anything minified.<source media>) — see optimize-load.Owned in depth by mobile-device-qa; the scene-side rules:
innerHeight as you scroll; a box sized 100dvh /
fixed inset-0 / innerHeight follows it, the renderer reallocates (a
cleared, blank frame) and redraws — "the scene flickers on scroll" (rule, 4
sites). Use src/components/common/scene-viewport.tsx /
src/utils/stable-viewport.ts: the box at 100lvh measured once, re-measured
on touch devices only on a width change.orientationchange on touch broke a
real width change (a DevTools preset back to desktop, split screen) — the
canvas stayed phone-sized (observed).setSize clears the buffer.lvh is for the canvas, not for static UI: a full-screen menu sized
inset-0/h-lvh hides its foot under Safari's bottom toolbar — menus are
top-0 h-dvh + safe-area padding.transform-gpu backface-hidden will-change-transform) — a neighbouring fixed element repainting can
invalidate the WebGL composite on WebKit.{ passive: true }. Clamp dt (§5).renderer.dispose(), forceContextLoss(), every listener.Prove it: node tools/qa/ios-toolbar-probe.mjs --url … --scroll 0.3 (steps the
height 844 → 760 → 844 …, asserts no buffer/box change and no blank frame,
then rotates and expects a resize). Headless height steps shrink lvh
sections with the window — a real iPhone doesn't; compare positions relative to
the section. Headless Chrome missed an iOS-only scene bug twice: when the bug is
iOS-only, reproduce it in WebKit (tools/qa/webkit-probe.mjs).
Re-measure the §0 numbers and report the delta honestly:
renderer.info.render.calls and .programs.length before vs after (raw
WebGL: __p.draws, __p.links.length); the program count must be stable
after the loader. On a raw scene every links timestamp must precede the
handoff.tools/qa/fps-probe.mjs — the scene's draws/s at rest and scrolling, phone
and desktop; no fixed caps (§5).tools/qa/scroll-test.mjs — PC (wheel) and mobile (touch, 4× CPU), cold and
warm. The bar: no frame over 50 ms, ≤ 1 % dropped, both devices.tools/qa/lighthouse.mjs people and --as-bot — the robot form must show no
three.js in its network list.tools/qa/ios-toolbar-probe.mjs and tools/qa/context-loss-probe.mjs for any
full-viewport or long-lived scene.programs.length does not grow. tools/qa/resize-check.mjs automates the
device-preset switch.Then, in the same turn, update the vault: obsidian/meta/changelog.md with the
before/after numbers; a trade-off you chose (a look sacrificed for a tier) → an
ADR in decisions-log.md with the reasoning; a measured fix that would help
the next project → obsidian/knowledge/fix-catalog.md (evidence, rule vs
observed); something that misled you → obsidian/knowledge/pitfalls.md.
When the scene's set-up is one 300 ms+ task (env-map build, shader compile,
three.js evaluation) that no step-splitting cures, render it in a worker:
canvas.transferControlToOffscreen() → a module worker runs the unchanged
scene code; the page forwards pointer, visibility and size. Rule (3 sites:
mobile 91 → 98, 86 → 91; TBT 298 → 60 ms) — with limits, each learned the hard
way:
load, in its own task. Started during hydration it made
TBT worse (553 vs 448 ms); after load 409 ms.mobile-device-qa.)device.ts/ticker — extend the one in the project.lil-gui hidden — it is still parsed. Tree-shake it behind a dev
flag. No console.log, Stats or disabled OrbitControls in production.© textura-agency, Unlicense. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file
SKILL.md and 1 other file (references) in .claude/skills/optimize-3d-scene of textura-agency/next16-claude-starter.
Open the folder on GitHubat commit 1170942
Optimize 3D Scene next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.
| Skill | Stars | Used in | Tokens | Auto-check | Licence | Repo updated |
|---|---|---|---|---|---|---|
| Optimize 3D Scene this skilltextura-agency/next16-claude-starter | 135 | — | ~9.5k | Automated safety check: Pass | Unlicense | |
| Threejs Capacitor iOSchongdashu/vibejam-starter-pack | 149 | — | ~1.6k | Automated safety check: Pass | None | |
| Image to Three.js Modelimg2threejs/img2threejs | 18k | 1 repos | ~8.2k | Automated safety check: Pass | Apache-2.0 | |
| Web CloneJane-xiaoer/claude-skill-web-clone | 1k | 1 repos | ~2.7k | Automated safety check: Pass | MIT | |
| Threejs Game Directormajidmanzarpour/threejs-game-skills | 2.5k | — | ~2.2k | Automated safety check: Pass | MIT | |
| Threejs Gameplay Systemsvalkor-ai/loom | 1.2k | 1 repos | ~1.4k | Automated safety check: Pass | Apache-2.0 |
chongdashu/vibejam-starter-pack
Build and ship Three.js apps on Capacitor iOS with Vite and Swift Package Manager: GLTF loading, assetsindex animation UI, OrbitControls mouse/touch mappings, and iOS sync/run troubleshooting.
img2threejs/img2threejs
Rebuilds the object in a reference image as a procedural, animation-ready Three.js model written entirely in code, using staged sculpting with quality checks.
Jane-xiaoer/claude-skill-web-clone
网站复刻 / 克隆方法论。USE WHEN 用户说 复刻网站、克隆网站、clone website、抄个站、仿站、 照着这个站做一个、reproduce site、还原某个网页效果、把这个站搬下来改成我的、 复刻某个交互/WebGL/Canvas/Three.js 效果。提供「先拿真源码 → 判路径 → 逆向拆解 → 搭工程 → 替换内容」的可移植决策树,覆盖静态站 /…
majidmanzarpour/threejs-game-skills
Entrypoint for building, upgrading, and finishing Three.js browser games.
valkor-ai/loom
Build and iterate playable Three.js game systems: starter scaffold, architecture, design briefs, core loops, level and encounter design, entities, input, camera, collision and physics, scoring…
calesthio/OpenMontage
Build deterministic, editable, free-viewpoint Three.js worlds from text or structured briefs.
textura-agency/next16-claude-starter
Make a site behave on real phones — the defects no Lighthouse run or headless scroll test sees, learned from site owners reviewing production sites on an iPhone.
textura-agency/next16-claude-starter
Get a page into Lighthouse's green zone on desktop and mobile, for people AND for the robot form crawlers get — build it, audit all four categories (Performance, Accessibility, Best Practices, SEO)…
textura-agency/next16-claude-starter
Make a page in this starter actually smooth — build it, scroll it in real Chrome on PC and on an emulated phone, fix what the measurement blames, re-measure to prove it.
textura-agency/next16-claude-starter
Make a Payload admin feel like part of the site and explain itself — the skin re-tinted from the site's own tokens (calm, light, no added motion), the site's wordmark and favicon, a dashboard…
textura-agency/next16-claude-starter
Put a Payload CMS admin on a site built from this starter — every visible string and content photo editable, derived from the site's own content objects with the code's copy as the fallback, a…
textura-agency/next16-claude-starter
Answer Engine Optimisation — make the site citable by ChatGPT, Claude, Perplexity, Gemini and AI Overviews.
Categories
Optimise a three.js or raw WebGL scene for phones, 120 Hz panels and low-end devices — device tiering, prewarm-everything-under-the-loader so nothing compiles mid-scroll, in-view-only render loops…. Optimize 3D Scene is an agent skill from textura-agency/next16-claude-starter.js or raw WebGL scene for phones, 120 Hz panels and low-end devices — device tiering, prewarm-everything-under-the-loader so nothing compiles mid-scroll, in-view-only render loops, DPR and particle/bloom budgets (never a fixed phone frame cap), the 60 fps desktop cap, OffscreenCanvas worker scenes, GPU-side scroll transforms, compressed models, iOS toolbar/context-loss survival, and keeping the scene off the robot form.
Optimize 3D Scene fits situations like: the user says optimise the 3D; the scene lags on mobile; micro freezes / jank on scroll; make the scene mobile-friendly.
Run `npx skills add textura-agency/next16-claude-starter --skill optimize-3d-scene -a claude-code`. Or copy the skill folder (.claude/skills/optimize-3d-scene in textura-agency/next16-claude-starter) into .claude/skills/optimize-3d-scene in your project. Claude Code loads it when a task matches its description.
Run `npx skills add textura-agency/next16-claude-starter --skill optimize-3d-scene -a codex`. Or copy the skill folder (.claude/skills/optimize-3d-scene in textura-agency/next16-claude-starter) into .agents/skills/optimize-3d-scene in your project. Codex loads it when a task matches its description.
Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add textura-agency/next16-claude-starter --skill optimize-3d-scene -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/optimize-3d-scene, .gemini/skills/optimize-3d-scene, .github/skills/optimize-3d-scene and .opencode/skills/optimize-3d-scene in your project.
Going by SKILL.md and its folder, Optimize 3D Scene needs the command-line tools its instructions call (node, yarn, npx and git).
SKILL.md contains no URLs. Its commands use npx and git, which can reach the network depending on how they are called. This is read from the text; nothing was executed.
Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.
Optimize 3D Scene is published under the Unlicense licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.
About 9.5k tokens (SKILL.md is roughly 38k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full. Its references folder adds about 10k tokens, read only when the agent opens those files.
Skills that share tags, products or a category with Optimize 3D Scene: Threejs Capacitor iOS (chongdashu/vibejam-starter-pack, 149 stars), Image to Three.js Model (img2threejs/img2threejs, 18k stars), Web Clone (Jane-xiaoer/claude-skill-web-clone, 1k stars) and Threejs Game Director (majidmanzarpour/threejs-game-skills, 2.5k stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.
textura-agency (a GitHub organization) maintains it in textura-agency/next16-claude-starter, which has 135 GitHub stars. The repository holds 16 skills in this directory. The repository was last updated on October 10, 2026.
Source: textura-agency/next16-claude-starter on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.