소스 정보
- 저장소
- AxGord/claude-workflow
- 최근 소스 활동
- 2026년 7월 15일 20:54
- 감지된 SKILL.md 언어
- 영어
- 스타
- 5
- 포크
- 2
설치 방법
기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.
소스 파일 검토
설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.
메뉴
기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.
설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
직접 명령은 검토 Prompt를 거치지 않습니다. 실행하기 전에 소스를 확인하세요.
npx skills add https://github.com/AxGord/claude-workflow --skill domain-pixi명령은 한 줄로 유지됩니다. 복사하기 전에 가로로 스크롤해 전체 내용을 확인하세요.
로컬 사본을 원하시나요? SkillsMP에서 현재 제공할 수 있는 파일을 다운로드하세요.
SOC 직업 분류 기준
SKILL.md 표시 중
| name | domain-pixi |
| description | Pixi.js v8 gotchas — graphics, masking, text, shaders, Spine, capture |
A polygon with ~625 vertices renders fine as a sprite but produces NO clipping as a mask. Keep mask polygons to ≤20 vertices.
new Graphics().poly([...625pts]).fill(0xffffff) → assign as mask → no clippingsetMask({ mask, inverse: true }) does not work for Graphics masksThe StencilMask object is created (visible on _maskEffect) but inverse produces no visible change. Non-inverse normal mask works. Version qualifier: the inverse option only exists since v8.5.0 (earlier v8 ignores it entirely); this failure was observed on v8.6.x — retest on your version before building a workaround.
container.setMask({ mask: g, inverse: true }) to hide inside-shape content.fill() calls create separate shapes — unreliable as maskg.rect(a).fill().rect(b).fill() creates two fill shapes. For stencil masking, multi-fill Graphics can misbehave.
.fill() for any maskOuter boundary CW, hole CCW (Y-down screen coords). For a rhombus {top, left, bottom, right} as a hole inside a CW outer rect:
FillStyle has no fillRule field — evenodd is not available. v8 does offer Graphics.cut() (draw the hole path after the fill, then .cut()), but caveat: it attaches the hole only to the LAST drawn shape (its fill+stroke instruction pair), and a hole that is not completely inside that shape "will fail to cut correctly" (per its own doc). For multiple or edge-touching holes use a zero-width bridge instead:
const pts: number[] = [0, 0];
for (const h of holes) { // sorted by x
pts.push(h.topX, 0); // descend from top edge
pts.push(h.topX, h.topY);
// hole perimeter, CCW
pts.push(h.leftX, h.leftY, h.botX, h.botY, h.rightX, h.rightY, h.topX, h.topY);
pts.push(h.topX, 0); // ascend (same X = zero-width seam)
}
pts.push(W, 0, W, H, 0, H); // close outer rect
new Graphics().poly(pts).fill(color);
The zero-width bridge (same X down/up) is tolerated by Pixi's tessellator — produces a seam but no visible artifact when used as mask.
texture.frame after creation doesn't rebuild UVs reliablyconst sub = new Texture({ source: original.source, frame: new Rectangle(x, y, w, h) });
new Sprite(sub);
Adding a Graphics mask as a child of the container it masks makes it inherit the container's full transform chain (rotation, camera zoom, parallax).
.fill() has no fillRule optionFillStyle fields: color, alpha, texture, matrix, fill, textureSpace — no fillRule. TypeScript will error on .fill({ fillRule: 'evenodd' }). (textureSpace: 'local' | 'global', default 'local', controls whether texture coords are relative to each shape's bounds or to world space.)
sprite.anchor is both position reference AND rotation pivotsprite.anchor.set(ax, ay) + sprite.position.set(wx, wy) + sprite.rotation = θ:
(ax·width, ay·height) maps to world (wx, wy)Use this to rotate around a specific PNG feature (e.g. left rim of a hazard sprite).
addChild z-order + mask visibilitycontainer.addChild(a, b, c) — later = on topsprite.mask or container.mask is hidden from normal rendering (not drawn, only used for clipping)Mesh.geometry attribute buffers may be interleaved — never iterate aPosition with stride 2When a Geometry is built with multiple attributes that share one underlying Buffer (interleaved layout: [x, y, u, v, x, y, u, v, ...]), geometry.getBuffer('aPosition').data returns the WHOLE interleaved Float32Array, not a positions-only view. Iterating with for (i; i < data.length; i += 2) reads [x0, y0, x2, y2, ...] — actually [x0, u0, x2, u2, ...] — and produces garbage.
for (let i = 0; i < buf.length; i += 2) { x = buf[i]; y = buf[i+1]; } on interleaved geometrygeometry.attributes.aPosition.{stride, offset}) and use the actual stride. For 2 floats × 2 attrs (xy + uv), stride is 4 floats per vertex:
for (let k = 0; k < buf.length; k += 4) { const x = buf[k], y = buf[k+1], u = buf[k+2], v = buf[k+3]; }
(x, -1) because -1 is the Loop-Blinn hull UV sentinel — looks like "weird hull padding" until you trace the buffer layout.displayObject.toGlobal({x,y}) instead of hand-multiplying nested transformsWhen a scene has multiple nested scales (e.g. sceneRoot.scale = camera_fit, layers.scale = camera_zoom, mesh.scale = native_to_screen), computing canvas pixel coords by hand-multiplying through the chain is brittle and easy to get backwards. Pixi already exposes the matrix it actually uses to render.
canvas_x = (front.x + tile.x + local_x) * sceneRoot.scale * layers.scale * resolutionconst p = mesh.toGlobal({x: local_x, y: local_y}); const canvas_x = p.x * renderer.resolution;mesh.toLocal(globalPoint, fromObject?)window/canvas listeners don't respect Pixi eventMode consumptionPixi v8's federated event system (eventMode='static' + pointertap etc.) only governs dispatch within the Pixi scene graph. The original DOM pointerevent continues bubbling to anything attached via window.addEventListener('pointerdown', ...) or canvas.addEventListener(...) regardless of which Pixi child handled it.
Failure mode: a HUD/UI container is added to app.stage (sibling of the world sceneRoot) with interactive Graphics for buttons. Clicking a button fires the Pixi pointertap AND the global window pointerdown listener that was meant to handle "tap on world to trigger the primary action" — every panel click double-fires the world action.
eventMode='static' to "consume" the event for window listeners.if (ev.target !== app.canvas) return — the canvas IS the target for clicks anywhere over its rect, including over panel children.ev.clientY - canvas.getBoundingClientRect().top >= panelTopY.canvas.addEventListener with stopPropagation) — but only if you control which pointerevents reach that listener relative to Pixi's.Graphics.roundRect clamps the corner radius to half the smaller dimension — stacking pills with different aspect ratios desyncs cornersWhen two roundRect calls stack to fake a "raised button" effect (e.g. tall pill in fill color, short shadow inset at the bottom), passing the same r parameter to both does NOT give matching corners. Pixi clamps each call independently to min(width, height) / 2, so a roundRect(0, 0, w, 48, 24) keeps r=24 while roundRect(0, 42, w, 6, 24) is silently clamped to r=3. The wide pill curves inward at the bottom corners, but the short shadow inset has nearly square corners and extends across the FULL width — visible as a dark sliver leaking past the curved bottom of the pill.
const r = Math.min(h / 2, 28);
g.roundRect(0, 0, w, h, r).fill(fill)
.roundRect(0, h - 6, w, 6, r).fill(shadow); // r clamps to 3, leaks past the curve
const r = Math.min((h - 6) / 2, 28);
g.roundRect(0, 0, w, h, r).fill(shadow)
.roundRect(0, 0, w, h - 6, r).fill(fill);
Text — set TextStyle.paddingPixi v8 sizes the offscreen text texture using measureText. Firefox's measureText reports tighter advance widths than fillText actually paints, especially for bold/800/900 weights, so the rightmost 1–2 px of the rightmost glyph fall outside the texture and get clipped. Chrome/Safari measure more generously and don't show the bug. User reports it as "in Chrome works, in Firefox last letter is cut" — affects every label simultaneously (BALANCE → BALANC, $100.00 → $100.0, TOTAL WIN → TOTAL WI).
new TextStyle({ fontFamily: '...', fontSize: 18, fontWeight: '800', fill: 0xffe600 });
padding (≥16 px for bold-800/900 uppercase with letterSpacing on 11+ chars; 4–8 px is too tight and still clips trailing glyph) — Pixi inflates the canvas by padding on all sides, so Firefox's wider-than-measured glyphs still fit. Pixi v8.18+ centers visible content correctly with anchor 0.5 (no manual compensation needed); v8.6's updateQuadBounds had a -padding term that did shift content, but that was removed.
new TextStyle({ fontFamily: '...', fontSize: 18, fontWeight: '800', fill: 0xffe600, padding: 16 });
const TEXT_PADDING = 16) keeps it consistent.padding only inflates the texture; Pixi compensates so the visible text stays at text.position. text.width still reports measuredWidth + padding*2, so tight-pack columns may shift by ~8 px — verify layouts after applying.npx playwright install firefox + a small launcher script is enough.anchor: 0.5 centers measureText box, not visible ink — set trim: true on centered TextStylesEven with padding (gotcha #15) keeping FF from clipping, anchor: 0.5 + sprite.x = container_center does NOT visually center the painted glyphs in Firefox bold-800 stacks. text.getBounds() returns bounds based on glyph advance widths, but the actual ink sits asymmetrically inside that box: each glyph has its own left/right side-bearings, so an "S"-starting word lands ink slightly right of geometric center while a "C"-starting word lands ink slightly left. Direction and magnitude vary per text — typical observed range ±5–20 px — so a single horizontal compensation offset hacks one label and breaks the next.
sprite.x by a magic constant to recenter ink — works for one label, off-center for another.trim: true on the TextStyle. Pixi runs getCanvasBoundingBox on the rendered canvas, sets texture.frame to the actual ink extent (then re-pads). With anchor: 0.5, sprite center maps to ink center, not measureText-box center.
new TextStyle({ ..., padding: 16, trim: true });
trim for centered-anchor texts. With anchor: 0 (top-left labels), trim crops the natural ascender/descender whitespace at the top of the canvas, so the visible ink sits HIGHER than sprite.y than the untrimmed equivalent — stacked labels with hardcoded vertical offsets will overlap. Either parametrize the style helper (labelStyle(size, color, weight, trim = false)) and opt in only at centered call sites, or split into two separate styles.python -c "from PIL import Image; ..." works when Pixi's preserveDrawingBuffer=false makes JS-side drawImage of the WebGL canvas come back empty.app.canvas.clientWidth/Height, not container.clientWidth/Heightapp.renderer.resize(W, H) sets the canvas CSS size to W×H (with autoDensity: true; without it the canvas has no CSS size of its own). When the game letterboxes inside a wider container (landscape window), container.clientWidth > app.canvas.clientWidth. Overlays laid out using the container dims extend into unrendered space; centering is computed against a wider rect than the canvas occupies.
bg.rect(0, 0, container.clientWidth, container.clientHeight).fill(color) — bg overshoots the rendered region; "centered" text drifts toward one side.bg.rect(0, 0, app.canvas.clientWidth, app.canvas.clientHeight).fill(color) — matches the rendered region exactly.app.screen.width/height gives the logical (renderer) resolution before CSS scaling — use clientWidth/clientHeight for layout in CSS pixels.TilingSprite wrap-samples the whole TextureSource — never feed it an atlas sub-framePixi v8 TilingSprite repeats the entire backing TextureSource, not the frame rect of the Texture you pass. If the texture is a sub-region of a shared atlas page, every tile wrap boundary bleeds the adjacent atlas content (alpha-bleed rings, packed neighbours), producing periodic seam artifacts across the tiled surface.
new TilingSprite({ texture: atlas.textures['sky_gradient'], width, height });
// → seam every `tileScale * atlas.textures['sky_gradient'].height` px
TextureSources (own PNG, not atlas-packed).Sprite instead of tiling:
const src = atlasTex.source;
const { x: fx, y: fy, width: fw, height: fh } = atlasTex.textures['sky_gradient'].frame;
const cropped = new Texture({ source: src, frame: new Rectangle(fx, fy + cropTop, fw, fh - cropTop) });
const sprite = new Sprite(cropped);
sprite.width = sceneWidth;
sprite.height = fh - cropTop;
A Sprite uses CLAMP_TO_EDGE semantics within its frame — no wrap, no seam. Stretching a uniform-column strip horizontally is visually lossless.ubo: true + std140 block) — plain uniform float foo; with UniformGroup resource silently never syncs to GPUAdding a custom uniform to a Mesh shader by declaring uniform float uTime; at top level (outside any block) and passing new UniformGroup({ uTime: { value: 0, type: 'f32' } }) as a resource appears correct: the shader compiles, glProgram._uniformData.uTime exists (introspected with type float), shader.groups[99].resources[0] is the UniformGroup, and the generated sync function emits gl.uniform1f(ud["uTime"].location, v). JS-side mutations to _waveUniforms.uniforms.uTime advance correctly each frame. But the GPU never sees the value change — the shader behaves as if uTime is permanently 0. The fallback bind-group path (group index 99 for resources without a gpuProgram layout) generates a sync function that doesn't actually push plain uniforms.
The reliable path is std140 UBO:
uniform float uTime;
new UniformGroup({ uTime: { value: 0, type: 'f32' } });
new Shader({ glProgram, resources: { waveUniforms } });
#version 300 es):
layout(std140) uniform waveUniforms {
float uTime;
};
new UniformGroup(
{ uTime: { value: 0, type: 'f32' } },
{ ubo: true },
);
The resource KEY (waveUniforms) must match the GLSL block name. The std140 block size is padded to 16 bytes, so the underlying Float32Array is length 4 for a single float — write group.uniforms.uTime = t; group.update(); per frame.Per-frame update flow that works: bump _dirtyId via group.update() after writing — updateUniformGroup then calls syncUniformGroup which writes std140 into buffer.data, bumps buffer._updateID, and GlBufferSystem.updateBuffer re-uploads on the next bind.
Verification trick: read (window as any).__group.buffer.data[0] over 500 ms — if it advances but the rendered shader output stays frozen, the binding chain is broken (likely the plain-uniform fallback). If buffer.data[0] advances and pixel output also advances → UBO path is working.
precision <qual> float; — compileHighShaderGl does NOT inject oneWhen emitting WebGL2 shaders via compileHighShaderGl({ template: { vertex: vertexGlTemplate, fragment: fragmentGlTemplate }, bits: [...] }) and prepending #version 300 es, the fragment shader fails to link if any custom bit references a float-typed varying or uniform. Symptom: console fills with WebGL: INVALID_OPERATION: useProgram: program not valid warnings, the mesh draws silently disappear from the framebuffer (no exception thrown). Vertex stage compiles fine (ES 3.00 supplies an implicit highp for vertex floats); only fragment needs the explicit precision.
const fragment = `#version 300 es\n${compiled.fragment}`;
const fragment = `#version 300 es\nprecision highp float;\n${compiled.fragment}`;
The standard localUniformBitGl / globalUniformsBitGl shaders bundled with Pixi escape this because their fragment never reads a float — they only write outColor = vColor. Any custom bit doing mix(), smoothstep(), or reading a varying vec2 vUV triggers the precision-missing link error.
Ticker.shared, NOT just the external tween libPer-frame motion in a Pixi app often comes from TWO independent drivers: Pixi's own Ticker.shared (drives Spine.autoUpdate skeletons and any Ticker.shared.add(...) per-frame loop) AND a separate tween library (gsap, etc.) driving specific objects. Pausing only ONE freezes only what IT drives. Common failure when capturing a screenshot of a transient overlap/alignment: you pause gsap.globalTimeline (which froze the gsap-driven object), take the screenshot a few hundred ms later via your screenshot tool, and the ticker-driven objects have moved on — the exact frame you detected is gone, and the screenshot shows empty space where the detector said a sprite was.
window.__gsap.globalTimeline.pause(); // gsap object stops, but ticker-driven sprites move before the screenshot
window.__gsap.globalTimeline.pause();
ticker.stop(); // freezes Spine autoUpdate + every Ticker.shared sim loop
Ticker.shared when it isn't exposed on window: in spine-pixi-v8 every Spine instance with autoUpdate on holds the ticker at spineInstance._ticker (defaults to Ticker.shared; overridable via options.ticker) — walk the scene to any Spine and read ._ticker. (Also reachable via app.ticker if you have the Application; the Pixi Ticker class itself is usually minified / not global in a prod bundle.)ticker.start() + gsap.globalTimeline.resume().requestAnimationFrame callback that finds the frame (call pause() + stop() synchronously inside it), so no extra ticks elapse before the freeze. A poll-from-outside-then-pause loses frames to the round-trip latency.To find the exact time a prop breaks, an attachment appears/disappears, or a face changes mid-clip, scrubbing the clip and eyeballing screenshots is unreliable: a fast mid-motion pose can look like the event already happened. Concrete miss: a "prop break" was eyeballed at trackTime ~0.2s but the prop is still intact then; the real break is 0.933s — a ~0.7s error that shipped a mistimed banner.
Read the animation's AttachmentTimelines from skeleton data. The keyframe where a slot's attachment name changes (to the broken/cracked variant, or to/from null) is the exact event time:
const anim = spine.skeleton.data.animations.find(a => a.name === 'break_anim');
for (const tl of anim.timelines) {
if (tl.attachmentNames && tl.frames) { // AttachmentTimeline
const slot = spine.skeleton.data.slots[tl.slotIndex].name;
// tl.frames[i] = time (s), tl.attachmentNames[i] = attachment (or null)
}
}
// e.g. intact `prop_whole` → null AND `shard_1..8` pieces appear, all at t=0.933
Companion: to RENDER a specific frame for a visual check, set spine.autoUpdate = false, then:
const e = spine.state.setAnimation(0, clip, false);
e.trackTime = t;
spine.update(0);
Do NOT call skeleton.updateWorldTransform() directly in spine-pixi v8 — it requires a Physics argument and throws physics is undefined. spine.update(0) applies state + transforms correctly.
A BlurFilter (or any filter) attached via sprite.filters = [blur] forces a filter render pass that composites the filter's padded region even when the sprite is fully transparent (alpha = 0). The result is a faint soft-edged rectangle lingering on screen at the sprite's position — the blur edge-clamp of the (invisible) content.
alpha = 0 (dissolve/burn-out tween) but a faint box persists at its position until something else clears the filter.sprite.alpha → 0 and leave sprite.filters = [blur] attached, assuming alpha 0 hides everything. The empty filter region keeps compositing.sprite.filters = [] in the fade tween's onComplete (or whenever alpha hits ~0). An alpha-0 sprite with no filter renders nothing; with a filter it renders the region.sprite.filters.length 1→0 at alpha 0: rectangle present with the filter, gone without it.Pixi v8 uploads a TextureSource to the GPU lazily, on the FIRST render that binds it (GlTextureSystem.bind→_initSource→onSourceUpdate→texImage2D). A boot "warmup render" (app.render() once before revealing the scene) only uploads what's VISIBLE in that idle frame. Any texture whose only consumers are clips/states not shown at idle — a separate Spine FX atlas (flame, explosion, bonus), a rarely-used sprite page — is NOT uploaded at boot. It uploads the first time that clip renders: a one-time synchronous texImage2D of the WHOLE page on the gameplay frame that first shows it.
atlas.png, the whole page uploads at boot, so later clips packed into the SAME page (an action pose, a flag) pay nothing. Only pages with NO region visible at idle defer. (So a Spine action clip on the hero's already-visible atlas = free; the flame's SEPARATE atlas = a deferred stall.)const og = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (t, ...r) {
const gl = og.call(this, t, ...r);
const proto = Object.getPrototypeOf(gl), o = proto.texImage2D;
proto.texImage2D = function (...a) {
const w = typeof a[3]==='number'?a[3]:(a[a.length-1]?.width||0);
const h = typeof a[4]==='number'?a[4]:(a[a.length-1]?.height||0);
if (w*h) window.__up.push({ t: performance.now(), w, h, bytes: w*h*4 });
return o.apply(this, a);
};
return gl;
};
A big single texImage2D (e.g. 2018×2044 = 16.5 MB) landing a few ms before the visible transition, present only ONCE in the whole session, is the smoking gun. A page costs w·h·4 bytes of GPU RAM regardless of the PNG's compressed size (a 2.6 MB PNG → ~16.5 MB upload).renderer.texture.initSource(source) (idempotent — no-op if already resident). Reach the source of a spine-pixi-v8 atlas via Assets.get(atlasAlias).pages[i].texture.texture.source (the SpineTexture wrapper holds the Pixi Texture at .texture, whose .source is the TextureSource). Call once per standalone FX atlas right after the warmup app.render().A soft radial glow built from stacked ADDITIVE Spine/sprite slots, scaled up several×, shows concentric "Mach-band" rings on some mobile panels (and milder banding on iPhones) even though the source texture is a smooth gradient. Cause: across the falloff the additive contribution drops <1 LSB per screen pixel, so the 8-bit framebuffer holds one value for ~16 px then steps +1 — a ring at every step. Desktop GPUs/panels dither it away; many mobile panels render the raw 8-bit, so the rings show. Confirm by sampling a radial line of the captured frame: a banded glow holds RGB constant for 16–24 px then steps by exactly 1 ((70,131,194)→(70,132,195)), and ×6-amplifying (frame − local_sky) makes the rings pop.
TilingSprite of noise over the world, blendMode:'add', tiny alpha (~0.012 → contribution noise(0..255)·alpha ≈ 0..3 LSB, mean ~1.5). Added to app.stage ABOVE the world, BELOW the HUD. Backend-agnostic (works on WebGL AND WebGPU — no custom shader), one cheap quad (vs a full-frame render-target pass for a Filter), and it dithers BEFORE the framebuffer re-quantizes. Use scaleMode:'nearest', tileScale = 1/renderer.resolution (≈ one noise texel per device pixel), eventMode:'none'. Imperceptible at 1× (verify on the real idle scene), bands gone (radial flat-run maxrun drops from ~16–24 px to ~2–3 px).Filter is the "more correct" symmetric dither but costs: a full-frame render pass, and on a renderer that may pick WebGPU (Pixi v8 default if no preference set in app.init) you must supply BOTH a glProgram and a gpuProgram. The additive-noise sprite sidesteps all of that.Math.random(). Filling a 128×128 RGBA tile is ~49k draws; if the game seeds any sim state (enemy layout, spawn jitter) off the GLOBAL Math.random stream, consuming those draws at boot SHIFTS that layout and silently changes gameplay. Use a local mulberry32 (a fixed seed is ideal — the dither tile only needs to be uncorrelated, not unpredictable). A render-init utility must never consume the sim's RNG.g.texture(...) is UNCLICKABLE — containsPoint skips texture instructions; set an explicit hitAreaPixi v8 GraphicsContext.containsPoint iterates instructions and does if (!instruction.action || !path) continue; — texture instructions carry no path, so a Graphics that draws nothing but texture quads never hit-tests true. Symptom: eventMode: 'static' + onpointertap on a texture-only Graphics (e.g. an atlas-frame button) silently never fires; the same handler pattern works elsewhere because those draws include fill() shapes. No error, cursor may even change (cursor comes from the events system pre-hit in some paths) — the tap just doesn't land.
<Graphics eventMode="static" onpointertap={onTap}
draw={(g) => g.texture(frame, 0xffffff, -w/2, -h/2, w, h)} />
<Graphics eventMode="static" onpointertap={onTap}
hitArea={new Circle(0, 0, w / 2 - pad)} // or new Rectangle(...)
draw={(g) => g.texture(frame, 0xffffff, -w/2, -h/2, w, h)} />
−/+ made of 2 small rects) IS hit-testable but the target is a sliver — give it a generous hitArea rect too.fill() shape covering the intended target, or carry an explicit hitArea. hitArea also short-circuits per-shape testing — cheaper on complex draws.generateTexture of a TRIMMED Text at resolution≠1 renders shifted when composited into a RenderTexture atlas — bake untrimmed for atlas pagesBaking labels via renderer.generateTexture({ target: text, resolution: 2 }) where the TextStyle has trim: true produces a texture whose CONTENT lands offset (≈ the logical ink height upward) when the baked texture is drawn as a Sprite into another RenderTexture (an atlas page): the recorded frame rect is right, the pixels are not — on retina the atlas shows the label's bottom half at the frame position and garbles every drawn label. At resolution 1 the same pipeline renders perfectly, so the bug ONLY appears on DPR-2 devices — desktop verification with a CDP-forced DPR-1 viewport misses it completely.
style.trim = true → generateTexture({target, resolution}) → Sprite → atlas page.padding captured as a border (generateTexture({ target, frame: new Rectangle(-pad, -pad, w+2pad, h+2pad), resolution })); the padding is symmetric, so centering the FULL padded box visually centers the text. Subtract pad where the label butts another element.renderer.extract.canvas(new Texture({source: page.source}))) at BOTH resolutions — comparing only final screenshots hides whether the page or the frame math is at fault.texture.source.resolution = N after Texture.from(canvas) to "mark" a hi-res raster double-counts in any packer that multiplies tex.width * source.resolution — leave source.resolution at 1 and scale at draw time with explicit dw/dh.A TextStyle with fill: new FillGradient({ textureSpace: 'local', ... }) renders correctly on Text, but on BitmapText (v8 dynamic bitmap font) the glyphs come out SOLID in (approximately) the LAST stop's colour. Cause: DynamicBitmapFont._setupContext calls getCanvasFillStyle(style._fill, context) with NO textMetrics argument, so the local gradient is built over a 1×1 box — every glyph pixel below y=1 samples the final stop. Same for gradient strokes. No warning is emitted.
<BitmapText> with a vertical FillGradient fill/stroke.Text "fixes" the gradient but re-rasterizes + re-uploads the whole string every frame — the exact cost BitmapText was chosen to avoid for per-frame counters.BitmapText: fill = gradient END colour, stroke = stroke-gradient END colour (opaque).BitmapText (same text/anchor/pos): fill = gradient START colour, stroke = stroke-gradient START colour.container.mask = sprite (alpha mask) — verified working in v8.18.width — DynamicBitmapFont feeds stroke.width into the glyph padding and draw offsets (extraPadding = stroke.width, tx/ty ± width/2), so a stroke-less layer rasterises its glyphs SHIFTED vs a stroked one and its fill paints over the other layer's stroke edge (shows as "the top stroke is eaten"). For a TRANSLUCENT stroke (e.g. black @0.4) that must not double-darken under the mask: keep the visible stroke in the BOTTOM layer and give the TOP layer the same-width stroke with alpha: 0 — identical metrics, zero paint.{#if} block APPENDS its Pixi node — template order ≠ z-order; background layers must mount UNCONDITIONALLYpixi-svelte components call parentContext.addToParent(node) at mount time. Children that mount TOGETHER land in template order, but a {#if cond} block whose condition flips true LATER (data arrives, state changes mid-animation) appends its node to the END of the parent's children — ON TOP of everything already mounted, regardless of where the block sits in the template.
flameTex resolves after mount):
{#if flameTex}
<Graphics draw={drawFlame} /> <!-- background -->
{/if}
<BitmapText text={label} ... />
$effect runs graphics.clear() before every draw, so a null texture just draws nothing:
<Graphics draw={(g) => { if (flameTex) g.texture(flameTex, ...); }} />
<BitmapText text={label} ... />
General Playwright/browser-capture traps (stale headless AND headed screenshots,
persistent MCP browser cache, headless-vs-device GPU perf) live in the
browser-verify skill — load it alongside this one when verifying visually.
requestAnimationFrameTo verify/diagnose a per-frame animation artifact (judder, stutter, "moves every other frame"), read the object's position INSIDE a callback added to the actual render ticker (spineInstance._ticker.add(cb) — walk the scene for a Spine with _ticker, see #21), not a separate requestAnimationFrame loop you spin up. Your own rAF runs on a DIFFERENT clock than the game's update/render and ALIASES against it: when the game updates at a different effective rate than your sampler, you get bogus duplicate frames — a perfectly regular 0, X, 0, X per-frame delta that looks like a real every-other-frame stutter but is partly your sampling beat. (Headless Chrome makes this worse: observed _ticker.FPS bounced 113→120→151 across runs, and gsap/Pixi can run at mismatched rates so BOTH the tween-driven and ticker-driven motion appeared to update at ~60 while the ticker reported 120.)
0,X,0,X is partly the sampler beat, present even in a smooth build):
let prev; const loop = () => { const x = read(); /* delta = prev - x */ prev = x; requestAnimationFrame(loop); };
let ticker; walkScene(n => { if (n._ticker && n.skeleton) ticker = n._ticker; });
const xs = []; const cb = () => xs.push(read()); ticker.add(cb); /* …900ms… */ ticker.remove(cb);
// smooth build → every delta ≈ mean (e.g. 4.8–6.1 around 5.4); juddery → 0 / 2×mean alternation (zeroFrac ≈ 0.5)
zeroFrac (fraction of frames with ≈0 delta). A render-synced smooth build is zeroFrac ≈ 0; a real every-other-frame stutter is zeroFrac ≈ 0.5. Your-own-rAF can't tell them apart.while (acc >= SIM_STEP_MS) step(SIM_STEP_MS)) on a >62.5 Hz display releases a step only every ~N frames → the object visibly moves in quantized bursts. If the camera is static (so the object's own motion is the only thing on screen), this reads as judder. Fix: step with the real (capped) frame dt where determinism isn't required.A stroke: { color: 0x000000, alpha: 0.4 } on a TextStyle produces DARKER seams between adjacent letters: Pixi rasterises Text glyph-by-glyph (each glyph's strokeText composites separately even at letterSpacing 0), and BitmapText draws one quad per glyph — wherever two glyphs' stroke rings overlap, 40% black over 40% black ≈ 64%. Design tools (Figma) stroke the whole text outline once, so mocks show a uniform stroke — the game shows dark blotches between every letter pair.
stroke: { color: 0x000000, alpha: 0.4, width: 8 } — dark seams at every glyph junction.Text (infrequent updates): bottom Text with fill black + stroke black alpha 1, shown at alpha = 0.4 — a single rasterised texture is already flat, plain element alpha is uniform. Top Text with the real fill + SAME-width stroke at alpha 0 (metrics parity).BitmapText (per-frame counters): plain container alpha does NOT work — alpha applies per glyph-quad and re-compounds the seams. Wrap the stroke-layer BitmapText in a Container with filters: [new AlphaFilter({ alpha: 0.4 })] — the filter flattens the subtree to a texture before applying alpha. Cheap (label-sized pass), keeps BitmapText's no-reraster benefit.'', not by unmounting (see #29 late-mount z-order trap).textureSpace: 'local' on Text — the gradient axis anchors to the measured text box, NOT the padded texture, and shifts per stringPlacing a vertical FillGradient (stops at 0/1) on a padded Text shows only the middle of the ramp on the glyph ink — the mock's edge colours never appear (e.g. a salmon→red mock reads flat red). But computing stop offsets from naive texture-height fractions (padding + lineHeight) ALSO misses: the local axis is anchored to Pixi's measured text box, and empirically the ink lands at DIFFERENT axis fractions for different strings of the same style (observed ~0.12..0.92 for one 70px string vs ~0.24..1.0 for another — the sign/symbol mix changes measured bounds).
ink_y(f) = A + B·f, then solve gradient start.y/end.y so the ink sees the mock's sampled ramp band. Out-of-[0,1] start/end values are valid (canvas gradients accept off-canvas endpoints) — keep the pure design hexes in the stops and move the LINE, don't lerp the colours.f→colour curves between mock screenshot and render — both measured the same way, so mock zoom level doesn't matter.resolution: 1 half-res-blurs the filtered subtree on a DPR-2 canvas — pass resolution: 'inherit'Filter.defaultOptions in v8 is { resolution: 1, antialias: 'off', ... }. On a renderer at resolution: 2 (retina/mobile), ANY filtered container (AlphaFilter, BlurFilter-less passes included) is rendered into a texture at HALF the device resolution and upscaled — the subtree comes out visibly blurry while unfiltered siblings stay crisp. Classic symptom: a text stroke/silhouette layer flattened via AlphaFilter (gotcha #31) reads soft and low-contrast next to its crisp fill layer; users report it as "less contrast than the mock".
new AlphaFilter({ alpha: 0.4 }) — device-res-blind, blurry at DPR ≥ 2.new AlphaFilter({ alpha: 0.4, resolution: 'inherit', antialias: 'inherit' }) — renders the filter pass at the render target's resolution.filters = [...] on a project that inits Pixi with resolution: window.devicePixelRatio.deviceScaleFactor: 2 capture and zoom the edge.renderer.render; heavy in-loop captures and ~25fps video both LIE about per-frame motionComplement to #30: when you can't order your callback after every driver (gsap and the Pixi ticker each rAF-drive the scene, in either order), wrap the render call itself — it samples the exact scene state of each PRESENTED frame regardless of which driver updated last:
const orig = app.renderer.render.bind(app.renderer);
app.renderer.render = (...a) => { sample(node.toGlobal({x: 0, y: 0})); return orig(...a); };
renderer.extract.canvas per rAF stalls the main loop ~100 ms/frame, and in a two-driver gsap+ticker system that stall FABRICATED a sawtooth motion artifact that did not exist at real frame rates. Heavy capture perturbs the very timing under test.recordVideo; CDP screencast in headless can drop to ~6 fps) aliases high-Hz per-frame motion — fine for coarse visual confirmation, useless for per-frame jitter claims.