Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 25 additions & 20 deletions .claude/skills/pokebox-fragment-shader/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,44 +12,49 @@ When **adding an entirely new shader**, also update `src/types/index.ts` (add to
| # | File | What to do |
| --- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| 1 | `src/shaders/<shader>.frag` | Declare `uniform float uMyParam;` and use it in shader logic |
| 2 | `src/types/index.ts` | Add `<shaderPrefix>MyParam: number` to `AppConfig` |
| 3 | `src/data/defaults.ts` | Add `<shaderPrefix>MyParam: <default>,` with the hardcoded value being replaced |
| 4 | `src/three/buildCard.ts` | Add `['uMyParam', '<shaderPrefix>MyParam']` to the shader's entry in the uniform mapping object |
| 5 | `src/composables/useThreeScene.ts` | Add `[() => store.config.<shaderPrefix>MyParam, 'uMyParam']` to the shader's `UniformMap` array |
| 6 | `src/components/ShaderControlsPanel.vue` | Add slider definition `{ label: 'My param', key: '<shaderPrefix>MyParam', min, max, step }` to the shader's section |
| 2 | `src/types/index.ts` | Add `myParam: number` to the shader's config interface (e.g. `FlatsilverReverseConfig`), and add to `ShaderConfigs` |
| 3 | `src/data/defaults.ts` | Add `myParam: <default>,` to the shader's defaults section |
| 4 | `src/data/shaderRegistry.ts` | Add `{ uniform: 'uMyParam', configPath: 'shaderKey.myParam' }` to the shader's registry entry |
| 5 | `src/three/buildCard.ts` | Import the shader frag and add to `FRAGMENT_SHADERS` map (only for new shaders) |
| 6 | `src/components/ShaderControlsPanel.vue` | Add slider definition to the shader's `sections` entry — **this is the UI; without it the user has no controls** |

## Naming conventions

- **Shader uniform**: `u` prefix, PascalCase — `uGlareContrast`
- **Config key**: shader prefix + PascalCase — `masterBallGlareContrast`, `illustRareBarAngle`, `sirShineFrequency`
- **Config key**: camelCase property on the shader config interface — `glareContrast`
- **Registry configPath**: `shaderKey.propertyName` — `flatsilverReverse.rainbowScale`
- **Slider label**: Short human-readable — `'Glare contrast'`

## Shader prefixes by type

| Shader file | Config prefix |
| -------------------------------- | ------------- |
| `illustration-rare.frag` | `illustRare` |
| `special-illustration-rare.frag` | `sir` |
| `ultra-rare.frag` | `ultraRare` |
| `rainbow-rare.frag` | `rainbowRare` |
| `master-ball.frag` | `masterBall` |
| `reverse-holo.frag` | `reverseHolo` |
| `shiny-rare.frag` | `shinyRare` |
| Shader file | shaderKey (in types/defaults/registry) |
| -------------------------------- | -------------------------------------- |
| `illustration-rare.frag` | `illustrationRare` |
| `special-illustration-rare.frag` | `specialIllustrationRare` |
| `tera-rainbow-rare.frag` | `teraRainbowRare` |
| `tera-shiny-rare.frag` | `teraShinyRare` |
| `ultra-rare.frag` | `ultraRare` |
| `rainbow-rare.frag` | `rainbowRare` |
| `reverse-holo.frag` | `reverseHolo` |
| `flatsilver-reverse.frag` | `flatsilverReverse` |
| `master-ball.frag` | `masterBall` |
| `shiny-rare.frag` | `shinyRare` |

## Slider definition format

```ts
{ label: 'My param', key: 'prefixMyParam', min: 0, max: 5, step: 0.05 }
{ label: 'My param', prop: 'myParam', min: 0, max: 5, step: 0.05 }
// Optional suffix for display: suffix: '%' (multiplies by 100) or suffix: '°'
// Use { subsection: 'Section Name' } to group sliders visually
```

## Common pitfalls

1. **Forgetting `useThreeScene.ts`** — sliders will appear to work on load but won't update in real-time when dragged. `STYLE_UNIFORMS` in `buildCard.ts` only sets **initial** values; the `watchUniform` calls in `useThreeScene.ts` are what make sliders reactive at runtime. Both are required.
2. **Forgetting `buildCard.ts`** — uniform will be `undefined` at material creation, causing shader errors or zero values
3. **Using `blendScreen` for additive effects** — screen blend with near-zero values is a no-op; use `result +=` for additive highlights
4. **`uBackground` range is compressed** — values are mapped to ~0.37–0.63 (see `useThreeScene.ts` lines 272-273), so `abs(bg - 0.5)` maxes at ~0.13, not 0.5. Multiply to normalize if using for thresholds
1. **Forgetting `ShaderControlsPanel.vue`** — the shader will work but users will see "No controls available for this shader yet." instead of sliders. Every shader with configurable uniforms needs a `sections` entry in the Vue component.
2. **Forgetting `shaderRegistry.ts`** — the registry is the single source of truth. `buildCard.ts` reads initial values from it and `useUniformWatchers.ts` creates reactive watchers from it automatically. Without it, sliders won't update the shader in real-time.
3. **Forgetting `buildCard.ts`** — uniform will be `undefined` at material creation, causing shader errors or zero values
4. **Using `blendScreen` for additive effects** — screen blend with near-zero values is a no-op; use `result +=` for additive highlights
5. **`uBackground` range is compressed** — values are mapped to ~0.37–0.63 (see `useThreeScene.ts`), so `abs(bg - 0.5)` maxes at ~0.13, not 0.5. Multiply to normalize if using for thresholds

## Validation

Expand Down
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Pokebox is a Vue 3 + Three.js app that creates a parallax "window into a box" ef
- **Ultra Rare** (`ultra-rare.frag`): Metallic sparkle with fully parameterized brightness/contrast/bar controls
- **Rainbow Rare** (`rainbow-rare.frag`): Metallic sparkle spotlight + iridescent glitter from iri-7 texture, for etched SV_ULTRA double rares
- **Tera Rainbow Rare** (`tera-rainbow-rare.frag`): Rainbow holo overlay + metallic sparkle spotlight + dual etch sparkle layers, for Tera-tagged special illustration rares
- **Flatsilver Reverse** (`flatsilver-reverse.frag`): Inverted-mask flat silver rainbow sheen over card border/text areas (outside artwork window) with pointer-responsive spotlight, for FLAT_SILVER+REVERSE common/uncommon reverse holos
- **Master Ball** (`master-ball.frag`): Etch foil composite on card base for RAINBOW+ETCHED masterball holo cards
- Shared GLSL functions live in `src/shaders/common/` and are included via `#include` (resolved by `vite-plugin-glsl`):
- `common/blend.glsl` — blend modes (overlay, screen, color-dodge, hard-light, etc.)
Expand All @@ -56,6 +57,7 @@ Cards are assigned a `holoType` automatically by `mapHoloType()` in `cardCatalog
| Rarity | Foil Type | Shader |
|--------|-----------|--------|
| any | `RAINBOW` + `ETCHED` mask | `master-ball` |
| any | `FLAT_SILVER` + `REVERSE` mask | `flatsilver-reverse` |
| `SHINY_RARE` | any | `shiny-rare` |
| `SHINY_ULTRA_RARE` | any | `ultra-rare` |
| `SPECIAL_ILLUSTRATION_RARE` | `TERA` + `SHINY` tags | `tera-shiny-rare` |
Expand All @@ -68,7 +70,8 @@ Cards are assigned a `holoType` automatically by `mapHoloType()` in `cardCatalog
| `DOUBLE_RARE` | `SV_ULTRA` + `ETCHED` mask | `rainbow-rare` |
| `DOUBLE_RARE` / `ILLUSTRATION_RARE` | other | `illustration-rare` |
| `RARE` | `SV_HOLO` | `regular-holo` |
| `COMMON` / `UNCOMMON` / `RARE` (other) | any | `reverse-holo` |
| `COMMON` / `UNCOMMON` / `RARE` (other) | `FLAT_SILVER` + `REVERSE` mask | `flatsilver-reverse` |
| `COMMON` / `UNCOMMON` / `RARE` (other) | other | `reverse-holo` |

### Key modules

Expand Down Expand Up @@ -211,6 +214,7 @@ The shader test suite (`src/shaders/__tests__/`) includes:
3. List required uniforms in compilation test
4. Run `bun test:shader` to verify
5. Add uniform↔config mappings to `SHADER_UNIFORM_REGISTRY` in `src/data/shaderRegistry.ts`, defaults to `src/data/defaults.ts`, types to `AppConfig` in `src/types/`, and the fragment shader import to `buildCard.ts`
6. Add a UI section to `ShaderControlsPanel.vue` with slider definitions for all configurable uniforms — without this, the panel shows "No controls available" when viewing cards with the new shader

**When modifying shader uniforms**:

Expand Down
23 changes: 23 additions & 0 deletions src/components/ShaderControlsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,29 @@ const sections: ShaderSection[] = [
{ label: 'Base saturation', prop: 'baseSaturation', min: 0, max: 3, step: 0.05 },
],
},
{
id: 'flatsilver-reverse',
shaderKey: 'flatsilverReverse',
title: 'Flat Silver Reverse',
icon: '🪩',
items: [
{ subsection: 'Rainbow' },
{ label: 'Scale', prop: 'rainbowScale', min: 0.1, max: 5, step: 0.1 },
{ label: 'Tilt shift', prop: 'rainbowShift', min: 0, max: 5, step: 0.1 },
{ label: 'Saturation', prop: 'rainbowSaturation', min: 0, max: 2, step: 0.05 },
{ label: 'Opacity', prop: 'rainbowOpacity', min: 0, max: 1, step: 0.05, suffix: '%' },
{ subsection: 'Spotlight' },
{ label: 'Radius', prop: 'spotlightRadius', min: 0.1, max: 2, step: 0.05 },
{ label: 'Intensity', prop: 'spotlightIntensity', min: 0, max: 3, step: 0.05 },
{ subsection: 'Grain' },
{ label: 'Scale', prop: 'grainScale', min: 0.1, max: 2.0, step: 0.01 },
{ label: 'Intensity', prop: 'grainIntensity', min: 0, max: 1, step: 0.01 },
{ subsection: 'Overall' },
{ label: 'Brightness', prop: 'baseBrightness', min: 0.5, max: 2, step: 0.05 },
{ label: 'Contrast', prop: 'baseContrast', min: 0, max: 3, step: 0.05 },
{ label: 'Saturation', prop: 'baseSaturation', min: 0, max: 3, step: 0.05 },
],
},
]

function sliderProps(section: ShaderSection): string[] {
Expand Down
96 changes: 82 additions & 14 deletions src/composables/useCardLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function useCardLoader(renderer: WebGLRenderer) {
let birthdayTextures: BirthdayTextures | null = null
let glitterTexture: Texture | null = null
let noiseTexture: Texture | null = null
let grainTexture: Texture | null = null
let cardBackTexture: Texture | null = null

function clearCache(): void {
Expand Down Expand Up @@ -110,11 +111,21 @@ export function useCardLoader(renderer: WebGLRenderer) {
}
}

tracedLoad(frontUrl, `load-texture card-front ${id}`, (tex) => {
applyFilters(tex, true)
cardTex = tex
onReady()
})
tracedLoad(
frontUrl,
`load-texture card-front ${id}`,
(tex) => {
applyFilters(tex, true)
cardTex = tex
onReady()
},
undefined,
() => {
console.warn(`[useCardLoader] Failed to load front texture for card: ${entry.label ?? id}`)
store.addToast(`Failed to load card asset: ${entry.label ?? id}`)
onReady()
},
)

if (hasMask) {
tracedLoad(
Expand All @@ -126,7 +137,11 @@ export function useCardLoader(renderer: WebGLRenderer) {
onReady()
},
undefined,
() => onReady(), // mask file missing — continue without it
() => {
console.warn(`[useCardLoader] Failed to load mask texture for card: ${entry.label ?? id}`)
store.addToast(`Failed to load card asset: ${entry.label ?? id}`)
onReady()
},
)
}

Expand All @@ -140,7 +155,11 @@ export function useCardLoader(renderer: WebGLRenderer) {
onReady()
},
undefined,
() => onReady(), // etch file missing — continue without it
() => {
console.warn(`[useCardLoader] Failed to load foil texture for card: ${entry.label ?? id}`)
store.addToast(`Failed to load card asset: ${entry.label ?? id}`)
onReady()
},
)
}
})
Expand Down Expand Up @@ -178,11 +197,23 @@ export function useCardLoader(renderer: WebGLRenderer) {
}
}

tracedLoad(entry.front, `load-texture hero-front ${entry.id}`, (tex) => {
applyFilters(tex, true)
cardTex = tex
onReady()
})
const store = useAppStore()

tracedLoad(
entry.front,
`load-texture hero-front ${entry.id}`,
(tex) => {
applyFilters(tex, true)
cardTex = tex
onReady()
},
undefined,
() => {
console.warn(`[useCardLoader] Failed to load front texture for hero card: ${entry.label ?? entry.id}`)
store.addToast(`Failed to load card asset: ${entry.label ?? entry.id}`)
onReady()
},
)

if (hasMask) {
tracedLoad(
Expand All @@ -194,7 +225,11 @@ export function useCardLoader(renderer: WebGLRenderer) {
onReady()
},
undefined,
() => onReady(),
() => {
console.warn(`[useCardLoader] Failed to load mask texture for hero card: ${entry.label ?? entry.id}`)
store.addToast(`Failed to load card asset: ${entry.label ?? entry.id}`)
onReady()
},
)
}

Expand All @@ -208,7 +243,11 @@ export function useCardLoader(renderer: WebGLRenderer) {
onReady()
},
undefined,
() => onReady(),
() => {
console.warn(`[useCardLoader] Failed to load foil texture for hero card: ${entry.label ?? entry.id}`)
store.addToast(`Failed to load card asset: ${entry.label ?? entry.id}`)
onReady()
},
)
}
})
Expand Down Expand Up @@ -403,6 +442,33 @@ export function useCardLoader(renderer: WebGLRenderer) {
return noiseTexture
}

function loadGrainTexture(): Promise<Texture | null> {
if (grainTexture) return Promise.resolve(grainTexture)

const store = useAppStore()
return new Promise<Texture | null>((resolve) => {
loader.load(
'img/grain.webp',
(tex) => {
applyFilters(tex)
tex.wrapS = tex.wrapT = 1000 // RepeatWrapping
grainTexture = tex
resolve(grainTexture)
},
undefined,
() => {
console.warn('[useCardLoader] Failed to load texture: grain.webp')
store.addToast('Texture "grain.webp" could not be loaded')
resolve(null)
},
)
})
}

function getGrainTexture(): Texture | null {
return grainTexture
}

function loadCardBackTexture(): Promise<Texture | null> {
if (cardBackTexture) return Promise.resolve(cardBackTexture)

Expand Down Expand Up @@ -446,6 +512,8 @@ export function useCardLoader(renderer: WebGLRenderer) {
getGlitterTexture,
loadNoiseTexture,
getNoiseTexture,
loadGrainTexture,
getGrainTexture,
loadCardBackTexture,
getCardBackTexture,
}
Expand Down
3 changes: 3 additions & 0 deletions src/composables/useThreeScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,9 @@ export function useThreeScene(containerRef: Ref<HTMLElement | null>) {
// Load noise texture for master ball sparkle layer 2
cardLoader.loadNoiseTexture()

// Load grain texture for flatsilver-reverse foil surface
cardLoader.loadGrainTexture()

// Load card-back texture
cardLoader.loadCardBackTexture()

Expand Down
30 changes: 16 additions & 14 deletions src/data/__tests__/cardCatalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,16 @@ describe('mapHoloType', () => {
expect(mapHoloType('DOUBLE_RARE', 'SUN_PILLAR', 'HOLO')).toBe('double-rare')
})

it('returns illustration-rare for DOUBLE_RARE + non-SUN_PILLAR', () => {
it('returns illustration-rare for DOUBLE_RARE + non-SUN_PILLAR (non-FLAT_SILVER)', () => {
expect(mapHoloType('DOUBLE_RARE', 'SV_HOLO', 'HOLO')).toBe('illustration-rare')
expect(mapHoloType('DOUBLE_RARE', 'FLAT_SILVER', 'REVERSE')).toBe('illustration-rare')
})

it('returns flatsilver-reverse for FLAT_SILVER + REVERSE (any rarity)', () => {
expect(mapHoloType('DOUBLE_RARE', 'FLAT_SILVER', 'REVERSE')).toBe('flatsilver-reverse')
expect(mapHoloType('RARE', 'FLAT_SILVER', 'REVERSE')).toBe('flatsilver-reverse')
expect(mapHoloType('COMMON', 'FLAT_SILVER', 'REVERSE')).toBe('flatsilver-reverse')
expect(mapHoloType('UNCOMMON', 'FLAT_SILVER', 'REVERSE')).toBe('flatsilver-reverse')
expect(mapHoloType('PROMO', 'FLAT_SILVER', 'REVERSE')).toBe('flatsilver-reverse')
})

it('returns illustration-rare for ILLUSTRATION_RARE', () => {
Expand All @@ -46,22 +53,17 @@ describe('mapHoloType', () => {
expect(mapHoloType('RARE', 'SV_HOLO', 'HOLO')).toBe('regular-holo')
})

it('returns reverse-holo for RARE + non-SV_HOLO', () => {
expect(mapHoloType('RARE', 'FLAT_SILVER', 'REVERSE')).toBe('reverse-holo')
})

it('returns reverse-holo for COMMON and UNCOMMON', () => {
expect(mapHoloType('COMMON', 'FLAT_SILVER', 'REVERSE')).toBe('reverse-holo')
expect(mapHoloType('UNCOMMON', 'FLAT_SILVER', 'REVERSE')).toBe('reverse-holo')
it('returns null for RARE + unmapped foil type', () => {
expect(mapHoloType('RARE', 'SUN_PILLAR', 'HOLO')).toBeNull()
})

it('returns reverse-holo for unknown designation', () => {
expect(mapHoloType('PROMO', 'FLAT_SILVER', 'REVERSE')).toBe('reverse-holo')
it('returns null for unmapped designation', () => {
expect(mapHoloType('PROMO', 'SV_HOLO', 'HOLO')).toBeNull()
})

it('RAINBOW without ETCHED mask follows designation, not master-ball', () => {
expect(mapHoloType('COMMON', 'RAINBOW', 'REVERSE')).toBe('reverse-holo')
expect(mapHoloType('RARE', 'RAINBOW', 'HOLO')).toBe('reverse-holo')
it('returns null for RAINBOW without ETCHED mask on unmapped rarity', () => {
expect(mapHoloType('COMMON', 'RAINBOW', 'REVERSE')).toBeNull()
expect(mapHoloType('RARE', 'RAINBOW', 'HOLO')).toBeNull()
})
})

Expand Down
Loading