Created
April 24, 2025 22:43
-
-
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 …
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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