pixi-2d-lighting
**Scope**: Custom GLSL ES 3.0 normal map filters, per-pixel lighting, dynamic combat event reactions **Version range**: pixi.js ^8.5.0
$ npx -y skills add notque/vexjoy-agent --agent claude-codeHow it fires
How this agent gets triggered: by you, by Claude, or both.
- Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
- You can call itInvoke it directly when you want it.
Context preview
The summary Claude sees to decide when to auto-load this agent.
**Scope**: Custom GLSL ES 3.0 normal map filters, per-pixel lighting, dynamic combat event reactions **Version range**: pixi.js ^8.5.0
Agent definition
pixi-2d-lighting.mdPixiJS v8 2D Normal Map Lighting Reference
> **Scope**: Custom GLSL ES 3.0 normal map filters, per-pixel lighting, dynamic combat event reactions > **Version range**: pixi.js ^8.5.0
---
What Normal Maps Do
A normal map stores surface orientation as RGB. The fragment shader calculates light per pixel, giving flat sprites depth. Inspiration: Slay the Spire 2 achieves this with per-sprite normal maps + 1-3 dynamic lights.
---
Generating Normal Maps
| Tool | Type | Best for | |------|------|----------| | [NormalMap-Online](https://cpetry.github.io/NormalMap-Online/) | Browser, free | Quick iteration | | [Laigter](https://azagaya.itch.io/laigter) | Desktop, free | Batch processing | | [SpriteIlluminator](https://www.codeandweb.com/spriteilluminator) | Desktop, paid | Professional output |
Wrestling character settings: Strength 2.5-3.5, Level 7-8, Blur 1-2. Naming: `player-normal.webp` alongside `player-diffuse.webp`.
---
Custom Normal Map Filter (GLSL ES 3.0)
PixiJS v8 uses GLSL ES 3.0: `in`/`out` not `varying`, `texture()` not `texture2D`.
import { Filter, GlProgram } from 'pixi.js';
const VERTEX_SRC = `
#version 300 es
in vec2 aPosition;
out vec2 vTextureCoord;
uniform vec4 uInputSize;
uniform vec4 uOutputFrame;
uniform vec4 uOutputTexture;
void main(void) {
vec2 position = aPosition * uOutputFrame.zw + uOutputFrame.xy;
position.x = position.x * (2.0 / uOutputTexture.x) - 1.0;
position.y = position.y * (2.0 / uOutputTexture.y) - 1.0;
gl_Position = vec4(position, 0.0, 1.0);
vTextureCoord = aPosition * (uOutputFrame.zw * uInputSize.zw);
}
`;
const FRAGMENT_SRC = `
#version 300 es
precision mediump float;
in vec2 vTextureCoord;
out vec4 fragColor;
uniform sampler2D uTexture;
uniform sampler2D uNormalMap;
uniform vec3 uLightPos; // x,y normalized 0-1, z height 0.05-0.5
uniform vec3 uLightColor; // RGB 0-1
uniform float uLightIntensity; // 0-2
uniform float uAmbientLight; // 0-0.5
void main(void) {
vec4 diffuse = texture(uTexture, vTextureCoord);
if (diffuse.a < 0.01) { fragColor = diffuse; return; }
vec3 normal = normalize(texture(uNormalMap, vTextureCoord).rgb * 2.0 - 1.0);
vec3 lightDir = normalize(uLightPos - vec3(vTextureCoord, 0.0));
float diff = max(dot(normal, lightDir), 0.0);
vec3 lighting = uAmbientLight + uLightColor * diff * uLightIntensity;
fragColor = vec4(diffuse.rgb * lighting, diffuse.a);
}
`;
export interface LightSource {
x: number; y: number; z: number;
color: [number, number, number];
intensity: number;
}
export class NormalMapFilter extends Filter {
constructor(normalMapTexture: unknown) {
super({
glProgram: GlProgram.from({ vertex: VERTEX_SRC, fragment: FRAGMENT_SRC }),
resources: {
normalMapUniforms: {
uNormalMap: { value: normalMapTexture, type: 'sampler2D' },
uLightPos: { value: [0.5, 0.3, 0.2], type: 'vec3<f32>' },
uLightColor: { value: [1.0, 0.95, 0.8], type: 'vec3<f32>' },
uLightIntensity: { value: 1.0, type: 'f32' },
uAmbientLight: { value: 0.25, type: 'f32' },
},
},
});
}
set light(source: LightSource) {
const u = this.resources.normalMapUniforms.uniforms;
u.uLightPos = [source.x, source.y, source.z];
u.uLightColor = source.color;
u.uLightIntensity = source.intensity;
}
set ambientLight(value: number) { this.resources.normalMapUniforms.uniforms.uAmbientLight = value; }
}---
Applying to Combat Characters
Apply to character containers, not root scene.
const BASE_LIGHT: LightSource = { x: 0.4, y: 0.2, z: 0.25, color: [1.0, 0.92, 0.78], intensity: 1.1 };
export function PlayerSprite(): React.JSX.Element {
const containerRef = useRef<Container>(null);
const filterRef = useRef<NormalMapFilter | null>(null);
useEffect(() => {
if (!containerRef.current) return;
Assets.load('/sprites/player-normal.webp').then((normalTexture: Texture) => {
const filter = new NormalMapFilter(normalTexture);
filter.resolution = app.renderer.resolution;
filter.light = BASE_LIGHT;
if (containerRef.current) { containerRef.current.filters = [filter]; filterRef.current = filter; }
});
return () => { if (containerRef.current) containerRef.current.filters = []; };
}, [app.renderer.resolution]);
useEffect(() => {
return useCombatStore.subscribe(
(state) => state.lastEffectType,
(effectType) => { if (filterRef.current && effectType) reactLightToEffect(filterRef.current, effectType); }
);
}, []);
return <container ref={containerRef}><sprite texture={playerTexture} anchor={{ x: 0.5, y: 1 }} /></container>;
}---
Dynamic Light Reactions
const LIGHT_REACTIONS: Record<EffectType, LightAnimation> = {
strike: { targetLight: { x: 0.9, y: 0.5, z: 0.1, color: [1.0, 0.5, 0.1], intensity: 2.0 }, duration: 80, returnAfter: 300 },
aerial: { targetLight: { x: 0.5, y: 0.05, z: 0.4, color: [0.8, 0.9, 1.0], intensity: 1.5 }, duration: 150, returnAfter: 500 },
submission: { targetLight: { x: 0.3, y: 0.6, z: 0.15, color: [0.2, 0.6, 1.0], intensity: 1.3 }, duration: 200, returnAfter: 2000 },
block: { targetLight: { x: 0.5, y: 0.4, z: 0.1, color: [0.5, 0.8, 1.0], intensity: 1.8 }, duration: 60, returnAfter: 200 },
finisher: { targetLight: { x: 0.5, y: 0.5, z: 0.05, color: [1.0, 0.85, 0.0], intensity: 2.0 }, duration: 300, returnAfter: 1500 },
heal: { targetLight: { x: 0.5, y: 0.1, z: 0.3, color: [0.3, 1.0, 0.4], intensity: 1.2 }, duration: 400, returnAfter: 800 },
};
export function reactLightToEffect(filter: NormalMapFilter, effectType: string): void {
const reaction = LIGHT_REACTIONS[effectType as EffectType];
if (!reaction) return;
filter.light = reaction.targetLight;
setTimeout(() => { filter.light = BASE_LIGHT; }, reaction.returnAfter);
}---
Performance
| Scenario | Cost | |--
Read more
PixiJS v8 2D Normal Map Lighting Reference
> **Scope**: Custom GLSL ES 3.0 normal map filters, per-pixel lighting, dynamic combat event reactions > **Version range**: pixi.js ^8.5.0
---
What Normal Maps Do
A normal map stores surface orientation as RGB. The fragment shader calculates light per pixel, giving flat sprites depth. Inspiration: Slay the Spire 2 achieves this with per-sprite normal maps + 1-3 dynamic lights.
---
Generating Normal Maps
| Tool | Type | Best for | |------|------|----------| | [NormalMap-Online](https://cpetry.github.io/NormalMap-Online/) | Browser, free | Quick iteration | | [Laigter](https://azagaya.itch.io/laigter) | Desktop, free | Batch processing | | [SpriteIlluminator](https://www.codeandweb.com/spriteilluminator) | Desktop, paid | Professional output |
Wrestling character settings: Strength 2.5-3.5, Level 7-8, Blur 1-2. Naming: `player-normal.webp` alongside `player-diffuse.webp`.
---
Custom Normal Map Filter (GLSL ES 3.0)
PixiJS v8 uses GLSL ES 3.0: `in`/`out` not `varying`, `texture()` not `texture2D`.
import { Filter, GlProgram } from 'pixi.js';
const VERTEX_SRC = `
#version 300 es
in vec2 aPosition;
out vec2 vTextureCoord;
uniform vec4 uInputSize;
uniform vec4 uOutputFrame;
uniform vec4 uOutputTexture;
void main(void) {
vec2 position = aPosition * uOutputFrame.zw + uOutputFrame.xy;
position.x = position.x * (2.0 / uOutputTexture.x) - 1.0;
position.y = position.y * (2.0 / uOutputTexture.y) - 1.0;
gl_Position = vec4(position, 0.0, 1.0);
vTextureCoord = aPosition * (uOutputFrame.zw * uInputSize.zw);
}
`;
const FRAGMENT_SRC = `
#version 300 es
precision mediump float;
in vec2 vTextureCoord;
out vec4 fragColor;
uniform sampler2D uTexture;
uniform sampler2D uNormalMap;
uniform vec3 uLightPos; // x,y normalized 0-1, z height 0.05-0.5
uniform vec3 uLightColor; // RGB 0-1
uniform float uLightIntensity; // 0-2
uniform float uAmbientLight; // 0-0.5
void main(void) {
vec4 diffuse = texture(uTexture, vTextureCoord);
if (diffuse.a < 0.01) { fragColor = diffuse; return; }
vec3 normal = normalize(texture(uNormalMap, vTextureCoord).rgb * 2.0 - 1.0);
vec3 lightDir = normalize(uLightPos - vec3(vTextureCoord, 0.0));
float diff = max(dot(normal, lightDir), 0.0);
vec3 lighting = uAmbientLight + uLightColor * diff * uLightIntensity;
fragColor = vec4(diffuse.rgb * lighting, diffuse.a);
}
`;
export interface LightSource {
x: number; y: number; z: number;
color: [number, number, number];
intensity: number;
}
export class NormalMapFilter extends Filter {
constructor(normalMapTexture: unknown) {
super({
glProgram: GlProgram.from({ vertex: VERTEX_SRC, fragment: FRAGMENT_SRC }),
resources: {
normalMapUniforms: {
uNormalMap: { value: normalMapTexture, type: 'sampler2D' },
uLightPos: { value: [0.5, 0.3, 0.2], type: 'vec3<f32>' },
uLightColor: { value: [1.0, 0.95, 0.8], type: 'vec3<f32>' },
uLightIntensity: { value: 1.0, type: 'f32' },
uAmbientLight: { value: 0.25, type: 'f32' },
},
},
});
}
set light(source: LightSource) {
const u = this.resources.normalMapUniforms.uniforms;
u.uLightPos = [source.x, source.y, source.z];
u.uLightColor = source.color;
u.uLightIntensity = source.intensity;
}
set ambientLight(value: number) { this.resources.normalMapUniforms.uniforms.uAmbientLight = value; }
}---
Applying to Combat Characters
Apply to character containers, not root scene.
const BASE_LIGHT: LightSource = { x: 0.4, y: 0.2, z: 0.25, color: [1.0, 0.92, 0.78], intensity: 1.1 };
export function PlayerSprite(): React.JSX.Element {
const containerRef = useRef<Container>(null);
const filterRef = useRef<NormalMapFilter | null>(null);
useEffect(() => {
if (!containerRef.current) return;
Assets.load('/sprites/player-normal.webp').then((normalTexture: Texture) => {
const filter = new NormalMapFilter(normalTexture);
filter.resolution = app.renderer.resolution;
filter.light = BASE_LIGHT;
if (containerRef.current) { containerRef.current.filters = [filter]; filterRef.current = filter; }
});
return () => { if (containerRef.current) containerRef.current.filters = []; };
}, [app.renderer.resolution]);
useEffect(() => {
return useCombatStore.subscribe(
(state) => state.lastEffectType,
(effectType) => { if (filterRef.current && effectType) reactLightToEffect(filterRef.current, effectType); }
);
}, []);
return <container ref={containerRef}><sprite texture={playerTexture} anchor={{ x: 0.5, y: 1 }} /></container>;
}---
Dynamic Light Reactions
const LIGHT_REACTIONS: Record<EffectType, LightAnimation> = {
strike: { targetLight: { x: 0.9, y: 0.5, z: 0.1, color: [1.0, 0.5, 0.1], intensity: 2.0 }, duration: 80, returnAfter: 300 },
aerial: { targetLight: { x: 0.5, y: 0.05, z: 0.4, color: [0.8, 0.9, 1.0], intensity: 1.5 }, duration: 150, returnAfter: 500 },
submission: { targetLight: { x: 0.3, y: 0.6, z: 0.15, color: [0.2, 0.6, 1.0], intensity: 1.3 }, duration: 200, returnAfter: 2000 },
block: { targetLight: { x: 0.5, y: 0.4, z: 0.1, color: [0.5, 0.8, 1.0], intensity: 1.8 }, duration: 60, returnAfter: 200 },
finisher: { targetLight: { x: 0.5, y: 0.5, z: 0.05, color: [1.0, 0.85, 0.0], intensity: 2.0 }, duration: 300, returnAfter: 1500 },
heal: { targetLight: { x: 0.5, y: 0.1, z: 0.3, color: [0.3, 1.0, 0.4], intensity: 1.2 }, duration: 400, returnAfter: 800 },
};
export function reactLightToEffect(filter: NormalMapFilter, effectType: string): void {
const reaction = LIGHT_REACTIONS[effectType as EffectType];
if (!reaction) return;
filter.light = reaction.targetLight;
setTimeout(() => { filter.light = BASE_LIGHT; }, reaction.returnAfter);
}---
Performance
| Scenario | Cost | |--
Essays and writing behind this toolkit live at vexjoy.com. AI agents skip steps. "Looks correct" replaces running tests. "Trivial change" replaces verification.
Repo: notque/vexjoy-agent
Other agents on vexjoy-agent.
- ansible-automation-engineer
Ansible automation: playbooks, roles, collections, Molecule testing, Vault security.
Open agent - modules
**Scope**: Module selection patterns, builtin vs command/shell decisions, collection modules, and version-specific module changes **Version range**: ansible-core 2.14+ / Ansible Collections (community.general 7.0+) **Generated**: 2026-04-04 — verify against current Ansible
Open agent - testing
**Scope**: Molecule test scenarios, ansible-lint rules, idempotency validation, and check-mode patterns **Version range**: Molecule 6.0+ / ansible-lint 6.0+ / ansible-core 2.14+ **Generated**: 2026-04-04 — verify against current Molecule and ansible-lint documentation
Open agent - base-instructions
Universal operational rules injected by /do at agent dispatch. Domain-specific rules live in each agent's .md file.
Open agent - communication-patterns
**Scope**: Failure modes in agent output style — over-reporting, self-congratulation, verbose narration, and hedging. Covers what to detect and how to fix each. **Version range**: all versions **Generated**: 2026-05-11
Open agent - combat-effects-upgrade
Zero-dependency combat visual upgrades: CSS particle replacement, Framer Motion combat juice, CSS 3D card transforms.
Open agent

