Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
572
plugins/suaveplan-threejs/skills/threejs-shaders/SKILL.md
Normal file
572
plugins/suaveplan-threejs/skills/threejs-shaders/SKILL.md
Normal file
@@ -0,0 +1,572 @@
|
||||
---
|
||||
name: threejs-shaders
|
||||
description: Three.js shader programming adapted for React Three Fiber and Suaveplan R3F packages. Covers shaderMaterial in JSX, the @suaveplan/r3f-shaders library, GLSL vertex and fragment shaders with React refs, uniform animation via useFrame, the extend() pattern for custom JSX elements, and onBeforeCompile for material patching.
|
||||
---
|
||||
|
||||
This skill covers custom shader development in Three.js within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All code targets **React 19 + TypeScript + R3F** and follows Suaveplan CLAUDE.md conventions.
|
||||
|
||||
The user needs to write custom GLSL shaders for visual effects, procedural materials, particle systems, or compute-like operations in the GPU.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Three.js Shader Pipeline
|
||||
|
||||
Three.js shaders run on the GPU as GLSL programs:
|
||||
|
||||
- **Vertex Shader**: Transforms vertex positions (runs per vertex)
|
||||
- **Fragment Shader**: Determines pixel color (runs per fragment/pixel)
|
||||
- **Uniforms**: Values passed from JavaScript to GLSL (same for all vertices/fragments)
|
||||
- **Attributes**: Per-vertex data (position, normal, UV)
|
||||
- **Varyings**: Values interpolated from vertex to fragment shader
|
||||
|
||||
### R3F Shader Approach
|
||||
|
||||
| Three.js (Imperative) | R3F (Declarative) |
|
||||
|---|---|
|
||||
| `new ShaderMaterial({ uniforms, vertexShader, fragmentShader })` | `<shaderMaterial>` JSX or `extend()` |
|
||||
| `material.uniforms.time.value = t` | Ref + `useFrame` |
|
||||
| `new RawShaderMaterial(...)` | `<rawShaderMaterial>` |
|
||||
| Custom class extending Material | `extend({ CustomMaterial })` |
|
||||
|
||||
### Suaveplan R3F Packages
|
||||
|
||||
| Package | Purpose | Import |
|
||||
|---|---|---|
|
||||
| `@suaveplan/r3f-shaders` | Pre-built shader materials and utilities | `import { GlassMaterial, HologramMaterial, useShaderUniforms } from '@suaveplan/r3f-shaders'` |
|
||||
| `@suaveplan/r3f` | Core R3F utilities | `import { useR3F } from '@suaveplan/r3f'` |
|
||||
| `@suaveplan/r3f-performance` | Shader performance monitoring | `import { usePerformanceMonitor } from '@suaveplan/r3f-performance'` |
|
||||
|
||||
## ShaderMaterial in JSX
|
||||
|
||||
### Basic Custom Shader
|
||||
|
||||
```tsx
|
||||
import { useRef, useMemo } from 'react';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { ShaderMaterial, Color } from 'three';
|
||||
|
||||
const vertexShader = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
varying vec3 vNormal;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vNormal = normalize(normalMatrix * normal);
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = /* glsl */ `
|
||||
uniform float uTime;
|
||||
uniform vec3 uColor;
|
||||
varying vec2 vUv;
|
||||
varying vec3 vNormal;
|
||||
|
||||
void main() {
|
||||
float stripe = sin(vUv.y * 20.0 + uTime * 2.0) * 0.5 + 0.5;
|
||||
vec3 color = mix(uColor, uColor * 0.3, step(0.5, stripe));
|
||||
float fresnel = pow(1.0 - dot(vNormal, vec3(0.0, 0.0, 1.0)), 2.0);
|
||||
color += fresnel * 0.3;
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export function StripedMaterial({ color = '#4ecdc4' }: { color?: string }) {
|
||||
const materialRef = useRef<ShaderMaterial>(null);
|
||||
|
||||
const uniforms = useMemo(
|
||||
() => ({
|
||||
uTime: { value: 0 },
|
||||
uColor: { value: new Color(color) },
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (materialRef.current) {
|
||||
materialRef.current.uniforms.uTime.value = clock.elapsedTime;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh>
|
||||
<sphereGeometry args={[1, 64, 64]} />
|
||||
<shaderMaterial
|
||||
ref={materialRef}
|
||||
vertexShader={vertexShader}
|
||||
fragmentShader={fragmentShader}
|
||||
uniforms={uniforms}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Key Rule: useMemo for Uniforms
|
||||
|
||||
Always wrap uniforms in `useMemo`. Without it, R3F recreates the material every render:
|
||||
|
||||
```tsx
|
||||
// CORRECT: uniforms created once
|
||||
const uniforms = useMemo(() => ({
|
||||
uTime: { value: 0 },
|
||||
uResolution: { value: new Vector2(window.innerWidth, window.innerHeight) },
|
||||
}), []);
|
||||
|
||||
// WRONG: new uniforms object every render, causes material recreation
|
||||
const uniforms = {
|
||||
uTime: { value: 0 },
|
||||
};
|
||||
```
|
||||
|
||||
## extend(): Custom JSX Elements
|
||||
|
||||
For reusable shader materials, use `extend()` to register a custom Three.js class as a JSX element:
|
||||
|
||||
```tsx
|
||||
import { extend, useFrame } from '@react-three/fiber';
|
||||
import { shaderMaterial } from '@react-three/drei';
|
||||
import { Color, Vector2 } from 'three';
|
||||
import { useRef } from 'react';
|
||||
|
||||
// Create a typed shader material class
|
||||
const WaveMaterial = shaderMaterial(
|
||||
// Uniforms with defaults
|
||||
{
|
||||
uTime: 0,
|
||||
uColor: new Color('#ff6b6b'),
|
||||
uAmplitude: 0.3,
|
||||
uFrequency: 4.0,
|
||||
},
|
||||
// Vertex shader
|
||||
/* glsl */ `
|
||||
uniform float uTime;
|
||||
uniform float uAmplitude;
|
||||
uniform float uFrequency;
|
||||
varying vec2 vUv;
|
||||
varying float vElevation;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vec3 pos = position;
|
||||
float elevation = sin(pos.x * uFrequency + uTime) * uAmplitude
|
||||
+ sin(pos.z * uFrequency * 0.7 + uTime * 0.8) * uAmplitude * 0.5;
|
||||
pos.y += elevation;
|
||||
vElevation = elevation;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
|
||||
}
|
||||
`,
|
||||
// Fragment shader
|
||||
/* glsl */ `
|
||||
uniform vec3 uColor;
|
||||
uniform float uAmplitude;
|
||||
varying vec2 vUv;
|
||||
varying float vElevation;
|
||||
|
||||
void main() {
|
||||
float brightness = (vElevation / uAmplitude + 1.0) * 0.5;
|
||||
vec3 color = mix(uColor * 0.3, uColor, brightness);
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
// Register as JSX element
|
||||
extend({ WaveMaterial });
|
||||
|
||||
// TypeScript declaration for JSX
|
||||
declare module '@react-three/fiber' {
|
||||
interface ThreeElements {
|
||||
waveMaterial: JSX.IntrinsicElements['shaderMaterial'] & {
|
||||
uTime?: number;
|
||||
uColor?: Color;
|
||||
uAmplitude?: number;
|
||||
uFrequency?: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Usage component
|
||||
export function WavePlane() {
|
||||
const materialRef = useRef<typeof WaveMaterial.prototype>(null);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (materialRef.current) {
|
||||
materialRef.current.uTime = clock.elapsedTime;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[10, 10, 128, 128]} />
|
||||
<waveMaterial
|
||||
ref={materialRef}
|
||||
uColor={new Color('#4ecdc4')}
|
||||
uAmplitude={0.3}
|
||||
uFrequency={4}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## GLSL Built-in Variables
|
||||
|
||||
### Vertex Shader
|
||||
|
||||
| Variable | Type | Description |
|
||||
|---|---|---|
|
||||
| `position` | `vec3` | Vertex position (attribute) |
|
||||
| `normal` | `vec3` | Vertex normal (attribute) |
|
||||
| `uv` | `vec2` | Texture coordinate (attribute) |
|
||||
| `modelMatrix` | `mat4` | Object to world transform |
|
||||
| `viewMatrix` | `mat4` | World to camera transform |
|
||||
| `projectionMatrix` | `mat4` | Camera to clip space |
|
||||
| `modelViewMatrix` | `mat4` | `viewMatrix * modelMatrix` |
|
||||
| `normalMatrix` | `mat3` | Inverse transpose of modelView (for normals) |
|
||||
| `cameraPosition` | `vec3` | Camera world position |
|
||||
| `gl_Position` | `vec4` | **Output**: clip-space position |
|
||||
|
||||
### Fragment Shader
|
||||
|
||||
| Variable | Type | Description |
|
||||
|---|---|---|
|
||||
| `gl_FragCoord` | `vec4` | Pixel position in screen space |
|
||||
| `gl_FrontFacing` | `bool` | Whether fragment is on front face |
|
||||
| `gl_FragColor` | `vec4` | **Output**: pixel color (deprecated, use `out vec4`) |
|
||||
|
||||
## Common GLSL Patterns
|
||||
|
||||
### Fresnel Effect
|
||||
|
||||
```glsl
|
||||
// In vertex shader
|
||||
varying vec3 vViewDirection;
|
||||
varying vec3 vWorldNormal;
|
||||
|
||||
void main() {
|
||||
vec4 worldPos = modelMatrix * vec4(position, 1.0);
|
||||
vViewDirection = normalize(cameraPosition - worldPos.xyz);
|
||||
vWorldNormal = normalize((modelMatrix * vec4(normal, 0.0)).xyz);
|
||||
gl_Position = projectionMatrix * viewMatrix * worldPos;
|
||||
}
|
||||
|
||||
// In fragment shader
|
||||
void main() {
|
||||
float fresnel = pow(1.0 - dot(vViewDirection, vWorldNormal), 3.0);
|
||||
vec3 color = mix(vec3(0.1), vec3(0.0, 0.8, 1.0), fresnel);
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
```
|
||||
|
||||
### Noise Functions
|
||||
|
||||
```glsl
|
||||
// Simple 2D noise (include in fragment shader)
|
||||
float random(vec2 st) {
|
||||
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
}
|
||||
|
||||
// Simplex-like value noise
|
||||
float noise(vec2 st) {
|
||||
vec2 i = floor(st);
|
||||
vec2 f = fract(st);
|
||||
float a = random(i);
|
||||
float b = random(i + vec2(1.0, 0.0));
|
||||
float c = random(i + vec2(0.0, 1.0));
|
||||
float d = random(i + vec2(1.0, 1.0));
|
||||
vec2 u = f * f * (3.0 - 2.0 * f); // smoothstep
|
||||
return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
|
||||
}
|
||||
|
||||
// FBM (Fractal Brownian Motion) for complex noise
|
||||
float fbm(vec2 st) {
|
||||
float value = 0.0;
|
||||
float amplitude = 0.5;
|
||||
for (int i = 0; i < 5; i++) {
|
||||
value += amplitude * noise(st);
|
||||
st *= 2.0;
|
||||
amplitude *= 0.5;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
```
|
||||
|
||||
### SDF (Signed Distance Functions)
|
||||
|
||||
Common 2D SDFs: `sdCircle(p, r)` = `length(p) - r`, `sdBox(p, b)` = `length(max(abs(p) - b, 0.0))`. Use for procedural shapes, soft shadows, and UI in shaders. Combine with `smoothstep` for anti-aliased edges.
|
||||
|
||||
## @suaveplan/r3f-shaders Library
|
||||
|
||||
Pre-built shader materials from the Suaveplan shader library:
|
||||
|
||||
### Available Shaders
|
||||
|
||||
```tsx
|
||||
import {
|
||||
GlassMaterial,
|
||||
HologramMaterial,
|
||||
NoiseMaterial,
|
||||
GradientMaterial,
|
||||
FresnelMaterial,
|
||||
GridMaterial,
|
||||
DissolveEffect,
|
||||
} from '@suaveplan/r3f-shaders';
|
||||
|
||||
// Glass with custom refraction
|
||||
<GlassMaterial
|
||||
color="#88ccff"
|
||||
ior={1.45}
|
||||
thickness={0.3}
|
||||
distortion={0.1}
|
||||
chromaticAberration={0.02}
|
||||
/>
|
||||
|
||||
// Sci-fi hologram
|
||||
<HologramMaterial
|
||||
color="#00ff88"
|
||||
scanlineSpeed={2}
|
||||
fresnelIntensity={1.5}
|
||||
glitchIntensity={0.3}
|
||||
wireframe
|
||||
/>
|
||||
|
||||
// Procedural noise surface
|
||||
<NoiseMaterial
|
||||
color="#4a90d9"
|
||||
scale={3}
|
||||
speed={0.5}
|
||||
displacement={0.2}
|
||||
octaves={4}
|
||||
/>
|
||||
|
||||
// Two-color gradient
|
||||
<GradientMaterial
|
||||
colorA="#ff6b6b"
|
||||
colorB="#4ecdc4"
|
||||
direction="vertical"
|
||||
/>
|
||||
```
|
||||
|
||||
### Creating Custom Shaders for the Library
|
||||
|
||||
Follow the `@suaveplan/r3f-shaders` pattern:
|
||||
|
||||
```tsx
|
||||
// src/materials/my-effect/my-effect.ts
|
||||
import { shaderMaterial } from '@react-three/drei';
|
||||
import { Color } from 'three';
|
||||
|
||||
export const MyEffectMaterial = shaderMaterial(
|
||||
{ uTime: 0, uColor: new Color('#ffffff'), uIntensity: 1.0 },
|
||||
/* glsl */ `/* vertex shader */`,
|
||||
/* glsl */ `/* fragment shader */`
|
||||
);
|
||||
|
||||
// src/materials/my-effect/my-effect.types.ts
|
||||
import type { Color } from 'three';
|
||||
export interface MyEffectMaterialProps {
|
||||
uTime?: number;
|
||||
uColor?: Color | string;
|
||||
uIntensity?: number;
|
||||
}
|
||||
```
|
||||
|
||||
## Uniform Animation Patterns
|
||||
|
||||
### Time-Based Animation
|
||||
|
||||
```tsx
|
||||
import { useRef } from 'react';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import type { ShaderMaterial } from 'three';
|
||||
|
||||
export function AnimatedShader() {
|
||||
const matRef = useRef<ShaderMaterial>(null);
|
||||
|
||||
useFrame(({ clock, pointer }) => {
|
||||
if (!matRef.current) return;
|
||||
matRef.current.uniforms.uTime.value = clock.elapsedTime;
|
||||
matRef.current.uniforms.uMouse.value.set(pointer.x, pointer.y);
|
||||
});
|
||||
|
||||
// ... shader material with uTime and uMouse uniforms
|
||||
}
|
||||
```
|
||||
|
||||
### Reactive Uniforms (Props to Shader)
|
||||
|
||||
```tsx
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { Color } from 'three';
|
||||
import type { ShaderMaterial } from 'three';
|
||||
|
||||
interface ReactiveShaderProps {
|
||||
color: string;
|
||||
intensity: number;
|
||||
}
|
||||
|
||||
export function ReactiveShader({ color, intensity }: ReactiveShaderProps) {
|
||||
const matRef = useRef<ShaderMaterial>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!matRef.current) return;
|
||||
(matRef.current.uniforms.uColor.value as Color).set(color);
|
||||
matRef.current.uniforms.uIntensity.value = intensity;
|
||||
}, [color, intensity]);
|
||||
|
||||
// ... shader material
|
||||
}
|
||||
```
|
||||
|
||||
## onBeforeCompile: Patching Built-in Materials
|
||||
|
||||
Modify Three.js built-in materials by injecting GLSL:
|
||||
|
||||
```tsx
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import type { MeshStandardMaterial, Shader } from 'three';
|
||||
|
||||
export function PatchedMaterial() {
|
||||
const matRef = useRef<MeshStandardMaterial>(null);
|
||||
const shaderRef = useRef<Shader | null>(null);
|
||||
|
||||
const onBeforeCompile = useCallback((shader: Shader) => {
|
||||
shaderRef.current = shader;
|
||||
shader.uniforms.uTime = { value: 0 };
|
||||
|
||||
// Inject uniform declaration
|
||||
shader.vertexShader = shader.vertexShader.replace(
|
||||
'#include <common>',
|
||||
/* glsl */ `
|
||||
#include <common>
|
||||
uniform float uTime;
|
||||
`
|
||||
);
|
||||
|
||||
// Inject vertex displacement
|
||||
shader.vertexShader = shader.vertexShader.replace(
|
||||
'#include <begin_vertex>',
|
||||
/* glsl */ `
|
||||
#include <begin_vertex>
|
||||
transformed.y += sin(transformed.x * 4.0 + uTime * 2.0) * 0.2;
|
||||
`
|
||||
);
|
||||
}, []);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (shaderRef.current) {
|
||||
shaderRef.current.uniforms.uTime.value = clock.elapsedTime;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh>
|
||||
<planeGeometry args={[5, 5, 64, 64]} />
|
||||
<meshStandardMaterial
|
||||
ref={matRef}
|
||||
color="steelblue"
|
||||
onBeforeCompile={onBeforeCompile}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## RawShaderMaterial
|
||||
|
||||
For full control without Three.js built-in uniforms/attributes, use `<rawShaderMaterial>`. You must manually declare all attributes (`position`, `uv`), uniforms (`projectionMatrix`, `modelViewMatrix`), and set `precision`. Supports `glslVersion="300 es"` for GLSL 3.0.
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Minimize texture lookups**: Each `texture2D()` call has a cost. Cache results in variables.
|
||||
2. **Avoid branches**: GPU prefers math over `if/else`. Use `step()`, `smoothstep()`, `mix()` instead.
|
||||
3. **Limit loop iterations**: Unroll small loops. Keep `for` loops short and bounded.
|
||||
4. **Use mediump where possible**: `precision mediump float` saves bandwidth on mobile.
|
||||
5. **Pre-calculate on CPU**: Move invariant math to `useFrame` and pass as uniforms.
|
||||
|
||||
```glsl
|
||||
// SLOW: branching
|
||||
if (dist < radius) { color = vec3(1.0); }
|
||||
else { color = vec3(0.0); }
|
||||
|
||||
// FAST: step function
|
||||
color = vec3(step(dist, radius));
|
||||
|
||||
// SLOW: multiple conditions
|
||||
if (x > 0.0 && x < 1.0 && y > 0.0 && y < 1.0) { ... }
|
||||
|
||||
// FAST: step math
|
||||
float inside = step(0.0, x) * step(x, 1.0) * step(0.0, y) * step(y, 1.0);
|
||||
```
|
||||
|
||||
## Testing Shaders
|
||||
|
||||
### Vitest Tests
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ShaderMaterial, Color } from 'three';
|
||||
|
||||
describe('Custom shader material', () => {
|
||||
it('creates material with correct uniforms', () => {
|
||||
const material = new ShaderMaterial({
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uColor: { value: new Color('#ff0000') },
|
||||
},
|
||||
vertexShader: 'void main() { gl_Position = vec4(0.0); }',
|
||||
fragmentShader: 'void main() { gl_FragColor = vec4(1.0); }',
|
||||
});
|
||||
expect(material.uniforms.uTime.value).toBe(0);
|
||||
expect(material.uniforms.uColor.value).toBeInstanceOf(Color);
|
||||
});
|
||||
|
||||
it('updates uniform values', () => {
|
||||
const uniforms = { uTime: { value: 0 } };
|
||||
uniforms.uTime.value = 1.5;
|
||||
expect(uniforms.uTime.value).toBe(1.5);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Storybook Stories
|
||||
|
||||
```tsx
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { OrbitControls } from '@react-three/drei';
|
||||
import { WavePlane } from './wave-plane';
|
||||
|
||||
const meta: Meta<typeof WavePlane> = {
|
||||
title: 'R3F/Shaders/WavePlane',
|
||||
component: WavePlane,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Canvas camera={{ position: [5, 5, 5] }} style={{ height: 400 }}>
|
||||
<ambientLight intensity={0.5} />
|
||||
<Story />
|
||||
<OrbitControls />
|
||||
</Canvas>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
export const Default: StoryObj = {};
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
When writing shaders in R3F:
|
||||
|
||||
1. Wrap GLSL strings with `/* glsl */` template literal tag for IDE syntax highlighting
|
||||
2. Always `useMemo` for uniform objects to prevent material recreation
|
||||
3. Use `extend()` + `shaderMaterial` from drei for reusable shader elements
|
||||
4. Animate uniforms via `useFrame` and refs (not React state)
|
||||
5. Declare TypeScript JSX types when using `extend()`
|
||||
6. Use `@suaveplan/r3f-shaders` pre-built materials before writing custom GLSL
|
||||
7. Prefer `step`/`smoothstep`/`mix` over `if/else` in GLSL
|
||||
8. Pre-allocate all uniform objects (Color, Vector2, Vector3) in `useMemo`
|
||||
9. Use `onBeforeCompile` to patch built-in materials rather than rewriting from scratch
|
||||
10. Keep shader component files under 250 lines (split GLSL into separate strings or files)
|
||||
Reference in New Issue
Block a user