Skip to content

Instantly share code, notes, and snippets.

@timgcarlson
Created April 24, 2025 22:43
Show Gist options
  • Save timgcarlson/35220f962ee56422f2f3fcf76365671f to your computer and use it in GitHub Desktop.
Save timgcarlson/35220f962ee56422f2f3fcf76365671f to your computer and use it in GitHub Desktop.
A simple React Native demo using expo-gl to render a bouncing box with adjustable speed and dynamic color changes. Inspired by the classic DVD player screen savers. Taps toggle animation pause/resume, buttons control speed, and color changes are randomized with brightness-aware contrast adjustment. Includes WebGL shader setup and proper cleanup …
import { useEffect, useRef, useState } from "react";
import { Button, Dimensions, Pressable, View } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { ExpoWebGLRenderingContext, GLView } from "expo-gl";
import { DurantaButton } from "~/components";
export default function RNGLViewDemo() {
const PADDING = 20;
const MIN_SPEED = 0.25;
const MAX_SPEED = 7.5;
const screenWidth = Dimensions.get("window").width;
const canvasSize = Math.min(screenWidth - 2 * PADDING, 400);
const speedRef = useRef(1);
const colorRef = useRef<[number, number, number, number]>([0.96, 0.353, 0.047, 1]);
const squareColorRef = useRef<[number, number, number, number]>([1, 1, 1, 1]);
const glRef = useRef<ExpoWebGLRenderingContext | null>(null);
const programRef = useRef<WebGLProgram | null>(null);
const [speed, setSpeed] = useState(speedRef.current);
const [isPaused, setIsPaused] = useState(false);
const isPausedRef = useRef(isPaused);
const isScreenFocused = useIsFocused();
const isScreenFocusedRef = useRef(isScreenFocused);
useEffect(() => {
isScreenFocusedRef.current = isScreenFocused;
// Make sure to stop the animation when the screen is not focused
if (isScreenFocused && glRef.current) {
onContextCreate(
glRef.current,
isPausedRef,
isScreenFocusedRef,
speedRef,
colorRef,
squareColorRef,
programRef,
);
}
}, [isScreenFocused]);
const toggleAnimation = () => {
setIsPaused((prev) => {
isPausedRef.current = !prev;
return !prev;
});
};
const changeSpeed = (factor: number) => {
if (isPaused) return;
const newSpeed = Math.min(MAX_SPEED, Math.max(MIN_SPEED, speedRef.current * factor));
speedRef.current = newSpeed;
setSpeed(newSpeed);
};
const changeColor = () => {
const newColor: [number, number, number, number] = [
Math.random(),
Math.random(),
Math.random(),
1.0,
];
colorRef.current = newColor;
const brightness = 0.299 * newColor[0] + 0.587 * newColor[1] + 0.114 * newColor[2];
squareColorRef.current = brightness < 0.5 ? [1, 1, 1, 1] : [0, 0, 0, 1];
if (glRef.current) {
glRef.current.clearColor(...colorRef.current);
glRef.current.clear(glRef.current.COLOR_BUFFER_BIT);
if (programRef.current) {
const colorLocation = glRef.current.getUniformLocation(programRef.current, "u_color");
if (colorLocation) {
glRef.current.useProgram(programRef.current);
glRef.current.uniform4fv(colorLocation, squareColorRef.current);
}
}
}
};
return (
<View className="flex flex-1 items-center justify-center p-5 gap-6 bg-white">
<Pressable onPress={toggleAnimation}>
<GLView
style={{ width: canvasSize, height: canvasSize }}
className="border border-transparent shadow-md"
onContextCreate={(gl) => {
glRef.current = gl;
onContextCreate(
gl,
isPausedRef,
isScreenFocusedRef,
speedRef,
colorRef,
squareColorRef,
programRef,
);
}}
/>
</Pressable>
<View className="flex flex-row gap-4">
<Button
title="Slow down"
onPress={() => changeSpeed(0.7)}
disabled={isPaused || speed <= MIN_SPEED}
/>
<Button
title="Speed up"
onPress={() => changeSpeed(1.3)}
disabled={isPaused || speed >= MAX_SPEED}
/>
</View>
<DurantaButton className="px-4 py-2 rounded-lg" label="Change color" onPress={changeColor} />
</View>
);
}
function onContextCreate(
gl: ExpoWebGLRenderingContext,
isPausedRef: React.MutableRefObject<boolean>,
isScreenFocusedRef: React.MutableRefObject<boolean>,
speedRef: React.MutableRefObject<number>,
colorRef: React.MutableRefObject<[number, number, number, number]>,
squareColorRef: React.MutableRefObject<[number, number, number, number]>,
programRef: React.MutableRefObject<WebGLProgram | null>,
) {
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
gl.clearColor(...colorRef.current);
let vert: WebGLShader;
let frag: WebGLShader;
try {
vert = createShader(
gl,
gl.VERTEX_SHADER,
`
attribute vec2 a_position;
void main(void) {
gl_Position = vec4(a_position, 0.0, 1.0);
gl_PointSize = 100.0;
}
`,
);
frag = createShader(
gl,
gl.FRAGMENT_SHADER,
`
precision mediump float;
uniform vec4 u_color;
void main(void) {
gl_FragColor = u_color;
}
`,
);
} catch (error) {
console.error(error);
return;
}
gl.compileShader(vert);
gl.compileShader(frag);
const program = gl.createProgram();
if (!program) {
console.error("Failed to create program");
return;
}
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
gl.useProgram(program);
programRef.current = program;
const colorLocation = gl.getUniformLocation(program, "u_color");
if (colorLocation) {
gl.uniform4fv(colorLocation, squareColorRef.current);
}
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
const positionAttrib = gl.getAttribLocation(program, "a_position");
gl.enableVertexAttribArray(positionAttrib);
gl.vertexAttribPointer(positionAttrib, 2, gl.FLOAT, false, 0, 0);
let x = 0.0;
let y = 0.0;
let dx = 0.02;
let dy = 0.015;
const pointSize = 100.0;
const screenSizeX = gl.drawingBufferWidth;
const screenSizeY = gl.drawingBufferHeight;
function render() {
if (!isScreenFocusedRef.current) {
// Stop rendering if screen is not focused, otherwise it will keep running in the background
return;
}
const effectiveSpeed = isPausedRef.current ? 0 : speedRef.current;
x += dx * effectiveSpeed;
y += dy * effectiveSpeed;
const sizeInNdcX = (pointSize / screenSizeX) * 2.0;
const sizeInNdcY = (pointSize / screenSizeY) * 2.0;
if (x + sizeInNdcX / 2 > 1.0 || x - sizeInNdcX / 2 < -1.0) dx = -dx;
if (y + sizeInNdcY / 2 > 1.0 || y - sizeInNdcY / 2 < -1.0) dy = -dy;
const positions = new Float32Array([x, y]);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.POINTS, 0, 1);
gl.flush();
gl.endFrameEXP();
requestAnimationFrame(render);
}
render();
}
function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader {
const shader = gl.createShader(type);
if (!shader) {
throw new Error(
`Failed to create shader of type: ${type === gl.VERTEX_SHADER ? "VERTEX" : "FRAGMENT"}`,
);
}
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const errorMessage = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error(`Shader compile error: ${errorMessage}`);
}
return shader;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment