feat(immersive): add Phase 3 ambient motion shader
- Add shaders/ambient.frag with: - UV drift (slow wandering motion) - Zoom oscillation (breathing effect) - Color breathing (subtle brightness pulsing) - Configurable intensity via u_intensity uniform - Update ImmersiveAmbientCanvas.svelte: - Use ambient shader instead of static - Add animation loop with requestAnimationFrame - Frame throttling at 30fps for power efficiency - Visibility-aware: pauses when tab hidden - Props: enableMotion, intensity - Responds to enableMotion changes dynamically All motion computation is GPU-side. No CPU per-frame work except uniform updates (3 floats).
This commit is contained in:
1 parent
350352afb9
commit
c94857fc87
3 files changed
+200
-42
No files matched your search
@@ -3,7 +3,7 @@
|
|||||||
* Immersive Ambient Canvas
|
* Immersive Ambient Canvas
|
||||||
*
|
*
|
||||||
* WebGL2-based canvas for rendering the immersive background.
|
* WebGL2-based canvas for rendering the immersive background.
|
||||||
* Phase 2: Pre-blurred artwork with async loading.
|
* Phase 3: Ambient motion shader with UV drift, zoom, and color breathing.
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - WebGL2 context with graceful fallback
|
* - WebGL2 context with graceful fallback
|
||||||
@@ -11,7 +11,9 @@
|
|||||||
* - Automatic resize handling
|
* - Automatic resize handling
|
||||||
* - Performance metrics reporting
|
* - Performance metrics reporting
|
||||||
* - Async texture loading with cancellation
|
* - Async texture loading with cancellation
|
||||||
* - Smooth crossfade between textures
|
* - Ambient motion shader (GPU-only, no CPU per-frame work)
|
||||||
|
* - Visibility-aware animation (pauses when tab hidden)
|
||||||
|
* - Configurable motion intensity
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
@@ -30,17 +32,27 @@
|
|||||||
updateTextureCount,
|
updateTextureCount,
|
||||||
handleContextLost,
|
handleContextLost,
|
||||||
handleContextRestored,
|
handleContextRestored,
|
||||||
|
getConfig,
|
||||||
} from './ImmersiveRenderer';
|
} from './ImmersiveRenderer';
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
interface Props {
|
interface Props {
|
||||||
/** URL of the artwork to display */
|
/** URL of the artwork to display */
|
||||||
artworkUrl?: string;
|
artworkUrl?: string;
|
||||||
|
/** Enable ambient motion (default: true) */
|
||||||
|
enableMotion?: boolean;
|
||||||
|
/** Motion intensity override (0-1, default: from config) */
|
||||||
|
intensity?: number;
|
||||||
/** Called when WebGL initialization fails */
|
/** Called when WebGL initialization fails */
|
||||||
onFallback?: () => void;
|
onFallback?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { artworkUrl, onFallback }: Props = $props();
|
let {
|
||||||
|
artworkUrl,
|
||||||
|
enableMotion = true,
|
||||||
|
intensity,
|
||||||
|
onFallback,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
// Canvas reference
|
// Canvas reference
|
||||||
let canvasRef: HTMLCanvasElement | undefined = $state();
|
let canvasRef: HTMLCanvasElement | undefined = $state();
|
||||||
@@ -51,17 +63,26 @@
|
|||||||
let vao: WebGLVertexArrayObject | null = null;
|
let vao: WebGLVertexArrayObject | null = null;
|
||||||
let vertexBuffer: WebGLBuffer | null = null;
|
let vertexBuffer: WebGLBuffer | null = null;
|
||||||
let currentTexture: WebGLTexture | null = null;
|
let currentTexture: WebGLTexture | null = null;
|
||||||
let nextTexture: WebGLTexture | null = null;
|
|
||||||
let placeholderTexture: WebGLTexture | null = null;
|
let placeholderTexture: WebGLTexture | null = null;
|
||||||
|
|
||||||
// Uniform locations
|
// Uniform locations
|
||||||
let u_texture: WebGLUniformLocation | null = null;
|
let u_texture: WebGLUniformLocation | null = null;
|
||||||
|
let u_time: WebGLUniformLocation | null = null;
|
||||||
|
let u_intensity: WebGLUniformLocation | null = null;
|
||||||
|
|
||||||
// Animation state
|
// Animation state
|
||||||
let animationFrameId: number | null = null;
|
let animationFrameId: number | null = null;
|
||||||
let isInitialized = false;
|
let isInitialized = false;
|
||||||
|
let isAnimating = false;
|
||||||
let lastArtworkUrl = '';
|
let lastArtworkUrl = '';
|
||||||
let textureLoadId = 0; // For cancellation
|
let textureLoadId = 0;
|
||||||
|
let startTime = 0;
|
||||||
|
let lastFrameTime = 0;
|
||||||
|
let isVisible = true;
|
||||||
|
|
||||||
|
// Frame throttling for power efficiency
|
||||||
|
const TARGET_FPS = 30;
|
||||||
|
const FRAME_INTERVAL = 1000 / TARGET_FPS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize WebGL resources.
|
* Initialize WebGL resources.
|
||||||
@@ -77,11 +98,11 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create shader program
|
// Create shader program (use ambient shader for motion)
|
||||||
program = createShaderProgram(
|
program = createShaderProgram(
|
||||||
gl,
|
gl,
|
||||||
SHADERS.static.vertex,
|
SHADERS.ambient.vertex,
|
||||||
SHADERS.static.fragment
|
SHADERS.ambient.fragment
|
||||||
);
|
);
|
||||||
if (!program) {
|
if (!program) {
|
||||||
console.error('[ImmersiveCanvas] Failed to create shader program');
|
console.error('[ImmersiveCanvas] Failed to create shader program');
|
||||||
@@ -91,6 +112,8 @@
|
|||||||
|
|
||||||
// Get uniform locations
|
// Get uniform locations
|
||||||
u_texture = gl.getUniformLocation(program, 'u_texture');
|
u_texture = gl.getUniformLocation(program, 'u_texture');
|
||||||
|
u_time = gl.getUniformLocation(program, 'u_time');
|
||||||
|
u_intensity = gl.getUniformLocation(program, 'u_intensity');
|
||||||
|
|
||||||
// Create fullscreen quad
|
// Create fullscreen quad
|
||||||
const quad = createFullscreenQuad(gl);
|
const quad = createFullscreenQuad(gl);
|
||||||
@@ -109,8 +132,9 @@
|
|||||||
canvasRef.addEventListener('webglcontextlost', onContextLost);
|
canvasRef.addEventListener('webglcontextlost', onContextLost);
|
||||||
canvasRef.addEventListener('webglcontextrestored', onContextRestored);
|
canvasRef.addEventListener('webglcontextrestored', onContextRestored);
|
||||||
|
|
||||||
console.log('[ImmersiveCanvas] WebGL2 initialized successfully');
|
console.log('[ImmersiveCanvas] WebGL2 initialized with ambient shader');
|
||||||
isInitialized = true;
|
isInitialized = true;
|
||||||
|
startTime = performance.now();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,13 +142,8 @@
|
|||||||
* Clean up WebGL resources.
|
* Clean up WebGL resources.
|
||||||
*/
|
*/
|
||||||
function destroyWebGL(): void {
|
function destroyWebGL(): void {
|
||||||
// Cancel any pending texture loads
|
|
||||||
cancelAllLoads();
|
cancelAllLoads();
|
||||||
|
stopAnimation();
|
||||||
if (animationFrameId !== null) {
|
|
||||||
cancelAnimationFrame(animationFrameId);
|
|
||||||
animationFrameId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canvasRef) {
|
if (canvasRef) {
|
||||||
canvasRef.removeEventListener('webglcontextlost', onContextLost);
|
canvasRef.removeEventListener('webglcontextlost', onContextLost);
|
||||||
@@ -136,7 +155,7 @@
|
|||||||
program,
|
program,
|
||||||
vao,
|
vao,
|
||||||
vertexBuffer,
|
vertexBuffer,
|
||||||
textures: [currentTexture, nextTexture, placeholderTexture],
|
textures: [currentTexture, placeholderTexture],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,9 +164,10 @@
|
|||||||
vao = null;
|
vao = null;
|
||||||
vertexBuffer = null;
|
vertexBuffer = null;
|
||||||
currentTexture = null;
|
currentTexture = null;
|
||||||
nextTexture = null;
|
|
||||||
placeholderTexture = null;
|
placeholderTexture = null;
|
||||||
u_texture = null;
|
u_texture = null;
|
||||||
|
u_time = null;
|
||||||
|
u_intensity = null;
|
||||||
isInitialized = false;
|
isInitialized = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,12 +177,7 @@
|
|||||||
function onContextLost(event: Event): void {
|
function onContextLost(event: Event): void {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
console.warn('[ImmersiveCanvas] WebGL context lost');
|
console.warn('[ImmersiveCanvas] WebGL context lost');
|
||||||
|
stopAnimation();
|
||||||
if (animationFrameId !== null) {
|
|
||||||
cancelAnimationFrame(animationFrameId);
|
|
||||||
animationFrameId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleContextLost();
|
handleContextLost();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,54 +186,58 @@
|
|||||||
*/
|
*/
|
||||||
function onContextRestored(): void {
|
function onContextRestored(): void {
|
||||||
console.log('[ImmersiveCanvas] WebGL context restored');
|
console.log('[ImmersiveCanvas] WebGL context restored');
|
||||||
|
|
||||||
// Reinitialize everything
|
|
||||||
if (initWebGL()) {
|
if (initWebGL()) {
|
||||||
handleContextRestored();
|
handleContextRestored();
|
||||||
// Reload current texture
|
|
||||||
if (lastArtworkUrl) {
|
if (lastArtworkUrl) {
|
||||||
loadTexture(lastArtworkUrl);
|
loadTexture(lastArtworkUrl);
|
||||||
}
|
}
|
||||||
render();
|
startAnimation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle visibility change (pause when tab hidden).
|
||||||
|
*/
|
||||||
|
function onVisibilityChange(): void {
|
||||||
|
isVisible = document.visibilityState === 'visible';
|
||||||
|
if (isVisible && enableMotion && !isAnimating) {
|
||||||
|
startAnimation();
|
||||||
|
} else if (!isVisible) {
|
||||||
|
stopAnimation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load a pre-blurred texture from artwork URL.
|
* Load a pre-blurred texture from artwork URL.
|
||||||
* Handles async loading with cancellation support.
|
|
||||||
*/
|
*/
|
||||||
async function loadTexture(url: string): Promise<void> {
|
async function loadTexture(url: string): Promise<void> {
|
||||||
if (!gl || !isInitialized) return;
|
if (!gl || !isInitialized) return;
|
||||||
|
|
||||||
// Increment load ID for cancellation
|
|
||||||
const loadId = ++textureLoadId;
|
const loadId = ++textureLoadId;
|
||||||
const requestId = `artwork-${loadId}`;
|
const requestId = `artwork-${loadId}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load pre-blurred texture
|
|
||||||
const result = await loadBlurredTexture(gl, url, requestId);
|
const result = await loadBlurredTexture(gl, url, requestId);
|
||||||
|
|
||||||
// Check if this load was cancelled (newer load started)
|
|
||||||
if (loadId !== textureLoadId || !result) {
|
if (loadId !== textureLoadId || !result) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swap textures
|
|
||||||
if (currentTexture) {
|
if (currentTexture) {
|
||||||
gl.deleteTexture(currentTexture);
|
gl.deleteTexture(currentTexture);
|
||||||
}
|
}
|
||||||
currentTexture = result.texture;
|
currentTexture = result.texture;
|
||||||
lastArtworkUrl = url;
|
lastArtworkUrl = url;
|
||||||
|
|
||||||
// Update metrics
|
|
||||||
updateTextureCount(1, result.width * result.height * 4);
|
updateTextureCount(1, result.width * result.height * 4);
|
||||||
|
|
||||||
// Render with new texture
|
// If not animating, render single frame
|
||||||
render();
|
if (!isAnimating) {
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[ImmersiveCanvas] Texture loaded: ${result.width}x${result.height}`);
|
console.log(`[ImmersiveCanvas] Texture loaded: ${result.width}x${result.height}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignore abort errors
|
|
||||||
if (e instanceof DOMException && e.name === 'AbortError') {
|
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -226,13 +245,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the animation loop.
|
||||||
|
*/
|
||||||
|
function startAnimation(): void {
|
||||||
|
if (isAnimating || !isInitialized) return;
|
||||||
|
isAnimating = true;
|
||||||
|
lastFrameTime = performance.now();
|
||||||
|
animationFrameId = requestAnimationFrame(animationLoop);
|
||||||
|
console.log('[ImmersiveCanvas] Animation started');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the animation loop.
|
||||||
|
*/
|
||||||
|
function stopAnimation(): void {
|
||||||
|
if (animationFrameId !== null) {
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
animationFrameId = null;
|
||||||
|
}
|
||||||
|
isAnimating = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animation loop with frame throttling.
|
||||||
|
*/
|
||||||
|
function animationLoop(timestamp: number): void {
|
||||||
|
if (!isAnimating) return;
|
||||||
|
|
||||||
|
// Frame throttling for power efficiency
|
||||||
|
const elapsed = timestamp - lastFrameTime;
|
||||||
|
if (elapsed >= FRAME_INTERVAL) {
|
||||||
|
lastFrameTime = timestamp - (elapsed % FRAME_INTERVAL);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(animationLoop);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a single frame.
|
* Render a single frame.
|
||||||
*/
|
*/
|
||||||
function render(): void {
|
function render(): void {
|
||||||
if (!gl || !program || !vao || !isInitialized) return;
|
if (!gl || !program || !vao || !isInitialized) return;
|
||||||
|
|
||||||
const startTime = performance.now();
|
const frameStart = performance.now();
|
||||||
|
|
||||||
// Handle resize
|
// Handle resize
|
||||||
if (canvasRef) {
|
if (canvasRef) {
|
||||||
@@ -242,12 +299,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear
|
// Clear
|
||||||
gl.clearColor(0.039, 0.039, 0.043, 1.0); // #0a0a0b
|
gl.clearColor(0.039, 0.039, 0.043, 1.0);
|
||||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
// Use program
|
// Use program
|
||||||
gl.useProgram(program);
|
gl.useProgram(program);
|
||||||
|
|
||||||
|
// Set uniforms
|
||||||
|
const currentTime = (performance.now() - startTime) / 1000; // seconds
|
||||||
|
const motionIntensity = enableMotion
|
||||||
|
? (intensity ?? getConfig().ambientIntensity)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
gl.uniform1f(u_time, currentTime);
|
||||||
|
gl.uniform1f(u_intensity, motionIntensity);
|
||||||
|
|
||||||
// Bind texture
|
// Bind texture
|
||||||
gl.activeTexture(gl.TEXTURE0);
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
gl.bindTexture(gl.TEXTURE_2D, currentTexture || placeholderTexture);
|
gl.bindTexture(gl.TEXTURE_2D, currentTexture || placeholderTexture);
|
||||||
@@ -259,7 +325,7 @@
|
|||||||
gl.bindVertexArray(null);
|
gl.bindVertexArray(null);
|
||||||
|
|
||||||
// Report metrics
|
// Report metrics
|
||||||
const frameTime = performance.now() - startTime;
|
const frameTime = performance.now() - frameStart;
|
||||||
updateMetrics(frameTime);
|
updateMetrics(frameTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,19 +336,39 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// React to motion enable/disable
|
||||||
|
$effect(() => {
|
||||||
|
if (isInitialized) {
|
||||||
|
if (enableMotion && isVisible && !isAnimating) {
|
||||||
|
startAnimation();
|
||||||
|
} else if (!enableMotion && isAnimating) {
|
||||||
|
stopAnimation();
|
||||||
|
render(); // Render one static frame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (initWebGL()) {
|
if (initWebGL()) {
|
||||||
// Initial render
|
// Set up visibility listener
|
||||||
render();
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
|
||||||
// Load initial artwork if provided
|
// Load initial artwork if provided
|
||||||
if (artworkUrl) {
|
if (artworkUrl) {
|
||||||
loadTexture(artworkUrl);
|
loadTexture(artworkUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start animation if motion enabled
|
||||||
|
if (enableMotion) {
|
||||||
|
startAnimation();
|
||||||
|
} else {
|
||||||
|
render();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||||
destroyWebGL();
|
destroyWebGL();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -299,7 +385,6 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
/* Ensure canvas fills container */
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#version 300 es
|
||||||
|
/**
|
||||||
|
* Ambient Motion Fragment Shader
|
||||||
|
*
|
||||||
|
* Adds subtle GPU-driven motion to the background:
|
||||||
|
* - UV drift (slow wandering)
|
||||||
|
* - Zoom oscillation (breathing effect)
|
||||||
|
* - Color breathing (subtle brightness pulsing)
|
||||||
|
*
|
||||||
|
* All effects are controlled by u_intensity (0 = static, 1 = full motion).
|
||||||
|
* All computation is GPU-side - no CPU per-frame work.
|
||||||
|
*/
|
||||||
|
|
||||||
|
precision mediump float;
|
||||||
|
|
||||||
|
// Input from vertex shader
|
||||||
|
in vec2 v_texCoord;
|
||||||
|
|
||||||
|
// Output
|
||||||
|
out vec4 fragColor;
|
||||||
|
|
||||||
|
// Uniforms
|
||||||
|
uniform sampler2D u_texture;
|
||||||
|
uniform float u_time; // Time in seconds
|
||||||
|
uniform float u_intensity; // Motion intensity (0.0 - 1.0)
|
||||||
|
|
||||||
|
// Constants for motion parameters
|
||||||
|
const float DRIFT_SPEED_X = 0.08;
|
||||||
|
const float DRIFT_SPEED_Y = 0.06;
|
||||||
|
const float DRIFT_AMOUNT = 0.03;
|
||||||
|
|
||||||
|
const float ZOOM_SPEED = 0.05;
|
||||||
|
const float ZOOM_AMOUNT = 0.02;
|
||||||
|
|
||||||
|
const float BREATH_SPEED = 0.12;
|
||||||
|
const float BREATH_AMOUNT = 0.06;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 uv = v_texCoord;
|
||||||
|
vec2 center = vec2(0.5);
|
||||||
|
|
||||||
|
// UV drift - slow wandering motion
|
||||||
|
float driftX = sin(u_time * DRIFT_SPEED_X) * DRIFT_AMOUNT * u_intensity;
|
||||||
|
float driftY = cos(u_time * DRIFT_SPEED_Y * 1.3) * DRIFT_AMOUNT * u_intensity;
|
||||||
|
uv += vec2(driftX, driftY);
|
||||||
|
|
||||||
|
// Zoom oscillation - subtle breathing
|
||||||
|
float zoom = 1.0 + sin(u_time * ZOOM_SPEED) * ZOOM_AMOUNT * u_intensity;
|
||||||
|
uv = center + (uv - center) / zoom;
|
||||||
|
|
||||||
|
// Clamp UV to avoid edge artifacts
|
||||||
|
uv = clamp(uv, 0.0, 1.0);
|
||||||
|
|
||||||
|
// Sample texture
|
||||||
|
vec4 color = texture(u_texture, uv);
|
||||||
|
|
||||||
|
// Color breathing - subtle brightness pulsing
|
||||||
|
float breath = 1.0 + sin(u_time * BREATH_SPEED) * BREATH_AMOUNT * u_intensity;
|
||||||
|
color.rgb *= breath;
|
||||||
|
|
||||||
|
fragColor = color;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import fullscreenVert from './fullscreen.vert?raw';
|
|||||||
|
|
||||||
// Fragment shaders
|
// Fragment shaders
|
||||||
import staticFrag from './static.frag?raw';
|
import staticFrag from './static.frag?raw';
|
||||||
|
import ambientFrag from './ambient.frag?raw';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Available shader programs.
|
* Available shader programs.
|
||||||
@@ -27,6 +28,16 @@ export const SHADERS = {
|
|||||||
vertex: fullscreenVert,
|
vertex: fullscreenVert,
|
||||||
fragment: staticFrag,
|
fragment: staticFrag,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ambient shader: adds motion effects.
|
||||||
|
* UV drift, zoom oscillation, color breathing.
|
||||||
|
* Requires u_time and u_intensity uniforms.
|
||||||
|
*/
|
||||||
|
ambient: {
|
||||||
|
vertex: fullscreenVert,
|
||||||
|
fragment: ambientFrag,
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ShaderName = keyof typeof SHADERS;
|
export type ShaderName = keyof typeof SHADERS;
|
||||||
Reference in new issue
Block a user