From ff65d02d3ccd1b0527d9d803b66fbf403dcbce5d Mon Sep 17 00:00:00 2001 From: Christian Kirkegaard Date: Sun, 31 Mar 2024 01:19:54 +0100 Subject: [PATCH] V0.2 (#3) * Rewrite animation loop and examples * Fix lints * Change all the things --- example/app/(examples)/examples/page.js | 40 ++++ .../app/(examples)/examples/page.module.css | 7 + example/app/(home)/page.js | 17 ++ example/app/01-simple.js | 26 --- example/app/02-state.js | 61 ------ example/app/03-props.js | 41 ---- example/app/07-bounce-text.js | 52 ----- example/app/favicon.ico | Bin 25931 -> 0 bytes example/app/layout.js | 20 +- example/app/page.js | 97 --------- example/app/page.module.css | 4 - example/components/examples/BounceText.js | 70 +++++++ .../examples/Confetti.js} | 39 ++-- .../examples/Game.js} | 98 +++++---- .../examples/Math.js} | 0 example/components/examples/Simple.js | 65 ++++++ .../examples/Webgl.js} | 76 ++++--- example/hooks/useBoundingBox.js | 19 ++ example/hooks/useWindowSize.js | 19 ++ example/jsconfig.json | 5 + example/{app => styles}/globals.css | 99 +++------ jsconfig.json | 8 + src/hook/index.js | 196 +++++++++++------- 23 files changed, 550 insertions(+), 509 deletions(-) create mode 100644 example/app/(examples)/examples/page.js create mode 100644 example/app/(examples)/examples/page.module.css create mode 100644 example/app/(home)/page.js delete mode 100644 example/app/01-simple.js delete mode 100644 example/app/02-state.js delete mode 100644 example/app/03-props.js delete mode 100644 example/app/07-bounce-text.js delete mode 100644 example/app/favicon.ico delete mode 100644 example/app/page.js delete mode 100644 example/app/page.module.css create mode 100644 example/components/examples/BounceText.js rename example/{app/06-confetti.js => components/examples/Confetti.js} (82%) rename example/{app/05-game.js => components/examples/Game.js} (66%) rename example/{app/08-math.js => components/examples/Math.js} (100%) create mode 100644 example/components/examples/Simple.js rename example/{app/04-webgl.js => components/examples/Webgl.js} (73%) create mode 100644 example/hooks/useBoundingBox.js create mode 100644 example/hooks/useWindowSize.js create mode 100644 example/jsconfig.json rename example/{app => styles}/globals.css (53%) create mode 100644 jsconfig.json diff --git a/example/app/(examples)/examples/page.js b/example/app/(examples)/examples/page.js new file mode 100644 index 0000000..7cfd7ef --- /dev/null +++ b/example/app/(examples)/examples/page.js @@ -0,0 +1,40 @@ +import { Simple } from "components/examples/Simple"; +import { WebGL } from "components/examples/Webgl"; +import { Game } from "components/examples/Game"; +import { Confetti } from "components/examples/Confetti"; +import { BounceText } from "components/examples/BounceText"; +import { Math } from "components/examples/Math"; + +import styles from "./page.module.css"; + +const Examples = () => { + return ( +
+
+

Simple

+ +
+
+

Confetti

+ +
+
+

Bouncing text

+ +
+
+

WebGL

+ +

+ Source +

+
+
+

Game

+ +
+
+ ); +}; + +export default Examples; diff --git a/example/app/(examples)/examples/page.module.css b/example/app/(examples)/examples/page.module.css new file mode 100644 index 0000000..77a0dd8 --- /dev/null +++ b/example/app/(examples)/examples/page.module.css @@ -0,0 +1,7 @@ +.examples { + display: block; +} + +.examples section { + margin: 1rem 0; +} diff --git a/example/app/(home)/page.js b/example/app/(home)/page.js new file mode 100644 index 0000000..5a71e61 --- /dev/null +++ b/example/app/(home)/page.js @@ -0,0 +1,17 @@ +"use client"; + +import Link from "next/link"; + +import { WebGL } from "components/examples/Webgl"; + +export default function Home() { + return ( +
+

+ A tiny hook that'll help you write neat canvas things. If + you're familiar with processing it might seem familiar. +

+ +
+ ); +} diff --git a/example/app/01-simple.js b/example/app/01-simple.js deleted file mode 100644 index 0d91d59..0000000 --- a/example/app/01-simple.js +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; -import { useCanvas } from "@kirkegaard/react-use-canvas"; - -export function Simple() { - const radius = 20; - - const setup = ({ context, height, width }) => { - context.translate(width / 2, height / 2); - }; - - const draw = ({ context, time, height, width }) => { - context.clearRect(-width / 2, -height / 2, width, height); - - const x = (Math.cos(time / 20) * (radius * 2 - width)) / 2; - const y = (Math.sin(time / 10) * (radius * 2 - height)) / 2; - - context.beginPath(); - context.arc(x, y, radius, 0, 2 * Math.PI); - context.fillStyle = "white"; - context.fill(); - }; - - const { ref } = useCanvas({ setup, draw }); - - return ; -} diff --git a/example/app/02-state.js b/example/app/02-state.js deleted file mode 100644 index 08e715d..0000000 --- a/example/app/02-state.js +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; -import { useCanvas } from "@kirkegaard/react-use-canvas"; - -export function States() { - const radius = 50; - - const draw = ({ context, time, height, width }) => { - context.clearRect(0, 0, width, height); - - context.save(); - - context.translate(width / 2, height / 2); - - context.rotate(Math.sin((time / 2000) * Math.PI * 45)); - - context.beginPath(); - context.rect(-radius / 2, -radius / 2, radius, radius); - context.fillStyle = "rgba(255,255,255,0.2)"; - context.strokeStyle = "white"; - context.stroke(); - context.fill(); - - context.restore(); - }; - - const { ref, pause, setFPS } = useCanvas({ - draw, - options: { - height: 100, - width: 100, - }, - }); - - return ( -
-
- - -
-
- - -
- -
- ); -} diff --git a/example/app/03-props.js b/example/app/03-props.js deleted file mode 100644 index 725b359..0000000 --- a/example/app/03-props.js +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; -import { useCanvas } from "@kirkegaard/react-use-canvas"; - -export function Props() { - const draw = ({ context, time, height, width, isPaused, fps }) => { - context.clearRect(0, 0, width, height); - context.font = "14px sans-serif"; - context.fillStyle = "white"; - - const lineHeight = 16; - - context.fillText(`Frame count: ${time}`, 0, lineHeight); - context.fillText(`Size: ${width}x${height}`, 0, lineHeight * 2); - context.fillText(`FPS: ${fps}`, 0, lineHeight * 3); - context.fillText(`Is paused: ${isPaused}`, 0, lineHeight * 4); - }; - - const { ref, isPaused, setIsPaused } = useCanvas({ - draw, - options: { - height: 70, - width: 250, - }, - }); - - return ( -
-
- - { - setIsPaused(!isPaused); - }} - /> -
- -
- ); -} diff --git a/example/app/07-bounce-text.js b/example/app/07-bounce-text.js deleted file mode 100644 index 70f857d..0000000 --- a/example/app/07-bounce-text.js +++ /dev/null @@ -1,52 +0,0 @@ -"use client"; -import { useCanvas } from "@kirkegaard/react-use-canvas"; - -export function BounceText() { - const palette = [ - "#223e37", - "#38675b", - "#467f71", - "#58a789", - "#97c8b5", - "#fff4bf", - "#ffe87a", - "#ffca53", - "#ff893b", - "#e52738", - ]; - - const string = "CANVAS<3"; - const size = 42; - const spacing = 4; - const trail = palette.length - 1; - - const setup = ({ context }) => { - context.font = "bold 50px Arial Black"; - }; - - const draw = ({ context, time, height, width }) => { - context.clearRect(0, 0, width, height); - - for (let i = 0; i < string.length; i++) { - for (let j = 0; j <= trail; j++) { - const t = time - i * 15 + j * 2.5; - const y = j + Math.sin(t * 0.05) * 50; - const x = (size + spacing) * i - time * 2; - context.fillStyle = palette[j % palette.length]; - context.fillText( - string.charAt(i), - width + (x % width) - size / 2, - y + height / 2 - ); - } - } - }; - - const { ref } = useCanvas({ - setup, - draw, - options: { height: 200, width: 500 }, - }); - - return ; -} diff --git a/example/app/favicon.ico b/example/app/favicon.ico deleted file mode 100644 index 718d6fea4835ec2d246af9800eddb7ffb276240c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25931 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 LrJugUA?W`A8`#=m diff --git a/example/app/layout.js b/example/app/layout.js index 486124a..3d73875 100644 --- a/example/app/layout.js +++ b/example/app/layout.js @@ -1,5 +1,6 @@ +import Link from "next/link"; import { Inter } from "next/font/google"; -import "./globals.css"; +import "styles/globals.css"; const inter = Inter({ subsets: ["latin"] }); @@ -11,7 +12,22 @@ export const metadata = { export default function RootLayout({ children }) { return ( - {children} + +
+

useCanvas

+ + {children} +
+ ); } diff --git a/example/app/page.js b/example/app/page.js deleted file mode 100644 index b181c44..0000000 --- a/example/app/page.js +++ /dev/null @@ -1,97 +0,0 @@ -import Link from "next/link"; -import { Simple } from "./01-simple"; -import { States } from "./02-state"; -import { Props } from "./03-props"; -import { WebGL } from "./04-webgl"; -import { Game } from "./05-game"; -import { Confetti } from "./06-confetti"; -import { BounceText } from "./07-bounce-text"; -import { Math } from "./08-math"; - -import styles from "./page.module.css"; - -export default function Home() { - return ( -
-

useCanvas

-

- A tiny hook that'll help you write neat canvas things. If - you're familiar with processing it might seem familiar. -

-
-

Simple

-

- In its simplest form the hook takes a setup and a{" "} - draw function. You can mix and match them as you will but - worth keeping in mind is that setup is triggered once and{" "} - draw is triggered over and over again. -

-

- In return you'll get a ref which you need to attach - to your canvas. -

- -
-
-

State

-

- The hook exposes a few states. isPaused holds the pause - state. fps holds the current frames per second. -

-

- You'll of course also get setters for those props. Use{" "} - setIsPaused and setFPS to set the state - explicitly. Or use pause to toggle the pause state. -

- -
-
-

Props

-

- The draw function exposes a few props. Like setup{" "} - you'll get the usual context, height, - and width. -

-

- Besides that you'll also get time which is a frame - count and isPaused which should be self explanatory. -

- -
-
-

WebGL

-

- If you're willing to write a shader pipeline, you can even use it - to render shaders!{" "} - - Here's an old one from some time ago - - . I do have plans for integrating a proper pipeline in the hook but - for now you'll have to write your own. Or copy the one from the - examples :) -

- -
-
-

Game

-

You can even make games! Although its probably not practical :D

-

Controls: Arrow and A for shooting

- -
-
-

Confetti

-

How about some confetti?

- -
-
-

Bouncing text

-

Or how about some demo effects like a scroller

- -
-
-

Math

- -
-
- ); -} diff --git a/example/app/page.module.css b/example/app/page.module.css deleted file mode 100644 index 695d5e5..0000000 --- a/example/app/page.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.main { - width: 100%; - max-width: 800px; -} diff --git a/example/components/examples/BounceText.js b/example/components/examples/BounceText.js new file mode 100644 index 0000000..4d046ac --- /dev/null +++ b/example/components/examples/BounceText.js @@ -0,0 +1,70 @@ +"use client"; +import { useRef } from "react"; +import { useCanvas } from "@kirkegaard/react-use-canvas"; +import { useBoundingBox } from "hooks/useBoundingBox"; + +export function BounceText() { + const viewportRef = useRef(null); + const bounds = useBoundingBox({ ref: viewportRef }); + + const palette = [ + "#223e37", + "#38675b", + "#467f71", + "#58a789", + "#97c8b5", + "#fff4bf", + "#ffe87a", + "#ffca53", + "#ff893b", + "#e52738", + ]; + + const string = "CANVAS<3"; + const size = 45; + const spacing = -10; + const trail = palette.length - 1; + + const onInit = async () => { + const fontFile = new FontFace( + "Roboto", + "url(https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmYUtfBBc4AMP6lQ.woff2)" + ); + document.fonts.add(fontFile); + await fontFile.load(); + }; + + const onUpdate = () => { + context.font = `${size}px 'Roboto'`; + + context.clearRect(0, 0, width, height); + + for (let i = 0; i < string.length; i++) { + for (let j = 0; j <= trail; j++) { + const t = time / 8 - i * 15 + j * 2.5; + const y = j + Math.sin(t / 25) * 50; + const x = (size + spacing) * i - time / 8 + t * -0.5; + context.fillStyle = palette[j % palette.length]; + context.fillText( + string.charAt(i), + width + (x % width) - size / 2, + y + height / 2 + ); + } + } + }; + + const { ref, time, context, height, width } = useCanvas({ + onInit, + onUpdate, + isPlaying: true, + height: 200, + width: bounds.width, + }); + + return ( +
+ +
+ ); +} diff --git a/example/app/06-confetti.js b/example/components/examples/Confetti.js similarity index 82% rename from example/app/06-confetti.js rename to example/components/examples/Confetti.js index ff2e477..65d5883 100644 --- a/example/app/06-confetti.js +++ b/example/components/examples/Confetti.js @@ -40,21 +40,19 @@ const Canvas = styled.canvas` pointer-events: none; `; -let PARTICLES = []; - export const Confetti = () => { const particleCount = useRef(350); - const contextRef = useRef(null); + const PARTICLES = useRef([]); const W = 10; const H = 6; const SIZE = Math.max(W, H); - const Particle = (context, opt) => { + const Particle = (opt) => { const orig = opt; let { x, y, r, velocityX, velocityY, gravity, color, type } = opt; - const update = (time, width, height) => { + const update = () => { x += velocityX; y += velocityY; r += (velocityY * velocityX) / 180; @@ -91,9 +89,10 @@ export const Confetti = () => { }; const addParticles = (particleCount = 50) => { + if (PARTICLES.current.length >= 5000) return; for (let i = 0; i < particleCount; i++) { - PARTICLES.push( - Particle(contextRef.current, { + PARTICLES.current.push( + Particle({ x: randomFloat(SIZE * 2, width - SIZE * 2), y: randomFloat(-SIZE, -50), r: (randomFloat(0, 360) * Math.PI) / 180, @@ -107,35 +106,25 @@ export const Confetti = () => { } }; - const setup = ({ context }) => { - contextRef.current = context; - }; - - const draw = ({ context, time }) => { + const onUpdate = () => { context.clearRect(0, 0, width, height); - for (const [idx, particle] of PARTICLES.entries()) { + for (const [idx, particle] of PARTICLES.current.entries()) { const { x, y } = particle.getProps(); if (x < -SIZE || x > width + SIZE || y > height) { - PARTICLES.splice(idx, 1); + PARTICLES.current.splice(idx, 1); } - particle.update(time, width, height); + particle.update(); particle.draw(); } }; const { height, width } = useWindowSize(); - const { ref } = useCanvas({ - setup, - draw, - options: { - height, - width, - contextAttributes: { - antialias: false, - }, - }, + const { ref, context, time } = useCanvas({ + onUpdate, + height, + width, }); return ( diff --git a/example/app/05-game.js b/example/components/examples/Game.js similarity index 66% rename from example/app/05-game.js rename to example/components/examples/Game.js index 96229f2..fc1c8f2 100644 --- a/example/app/05-game.js +++ b/example/components/examples/Game.js @@ -32,13 +32,13 @@ const useInput = () => { export function Game() { const input = useInput(); - let ENEMIES = []; - let BULLETS = []; - let PLAYER; + const ENEMIES = useRef([]); + const BULLETS = useRef([]); + const PLAYER = useRef(null); - const State = { + const State = useRef({ gameOver: true, - }; + }); const Alien = (options, context) => { let { x, y, size = 10, speed = 2 } = options; @@ -46,8 +46,8 @@ export function Game() { const getProps = () => ({ x, y, size }); const update = ({ time }) => { - x += Math.sin(time * 0.05) / 2; - y += 0.25; + x += Math.sin(time / 500); + y += 0.75; }; const draw = () => { @@ -84,6 +84,8 @@ export function Game() { const Ship = (options, context) => { let { x, y, speed, size, isShooting = false } = options; + const getProps = () => ({ x, y, size }); + const update = ({ width }) => { if (input.current.includes("ArrowLeft")) { x = x <= 0 ? 0 : x - speed; @@ -94,7 +96,7 @@ export function Game() { if (input.current.includes("a")) { if (!isShooting) { isShooting = true; - BULLETS.push(Bullet({ x: x, y }, context)); + BULLETS.current.push(Bullet({ x: x, y }, context)); } } else { isShooting = false; @@ -109,15 +111,15 @@ export function Game() { context.closePath(); }; - return { update, draw }; + return { update, draw, getProps }; }; - const setup = ({ context, height, width }) => { + const onInit = () => { // Make sure the enemies array is empty because rerender things :( - ENEMIES = []; - BULLETS = []; + ENEMIES.current = []; + BULLETS.current = []; - PLAYER = Ship( + PLAYER.current = Ship( { x: width / 2, y: height - 20, speed: 2, size: 20 }, context ); @@ -129,12 +131,12 @@ export function Game() { for (let i = 1; i < col; i++) { for (let j = 1; j < row; j++) { - ENEMIES.push(Alien({ x: xc * i, y: yr * j }, context)); + ENEMIES.current.push(Alien({ x: xc * i, y: yr * j }, context)); } } }; - const draw = ({ context, time, height, width }) => { + const onUpdate = () => { context.clearRect(0, 0, width, height); context.beginPath(); context.rect(0, 0, width, height); @@ -142,15 +144,11 @@ export function Game() { context.stroke(); context.closePath(); - if (ENEMIES.length <= 0) { - // Reset - setup({ context, height, width }); - State.gameOver = true; - } - - if (State.gameOver) { + if (State.current.gameOver) { + onInit(); if (input.current.includes("Enter")) { - State.gameOver = false; + console.log("starting game"); + State.current.gameOver = false; } context.textAlign = "center"; @@ -160,40 +158,60 @@ export function Game() { return; } - PLAYER.update({ height, width }); - PLAYER.draw({ height, width }); + if (ENEMIES.current.length <= 0) { + State.current.gameOver = true; + } + + PLAYER.current.update({ height, width }); + PLAYER.current.draw({ height, width }); - for (const [enemyIdx, enemy] of ENEMIES.entries()) { + for (const [enemyIdx, enemy] of ENEMIES.current.entries()) { const enemyProps = enemy.getProps(); // If enemy reaches the end end the game if (enemyProps.y >= height - enemyProps.size) { - State.gameOver = true; + State.current.gameOver = true; } enemy.update({ time }); enemy.draw(); } - for (const [bulletIdx, bullet] of BULLETS.entries()) { + for (const [enemyIdx, enemy] of ENEMIES.current.entries()) { + const enemyProps = enemy.getProps(); + const playerProps = PLAYER.current.getProps(); + + if ( + enemyProps.y > height || + (enemyProps.y + enemyProps.size >= playerProps.y && + enemyProps.y - enemyProps.size <= playerProps.y && + enemyProps.x + enemyProps.size >= playerProps.x && + enemyProps.x - enemyProps.size <= playerProps.x) + ) { + State.current.gameOver = true; + } + } + + for (const [bulletIdx, bullet] of BULLETS.current.entries()) { const bulletProps = bullet.getProps(); // Check if any bullets are hitting the enemy - for (const [enemyIdx, enemy] of ENEMIES.entries()) { + for (const [enemyIdx, enemy] of ENEMIES.current.entries()) { const enemyProps = enemy.getProps(); + if ( enemyProps.y + enemyProps.size >= bulletProps.y && enemyProps.y - enemyProps.size <= bulletProps.y && enemyProps.x + enemyProps.size >= bulletProps.x && enemyProps.x - enemyProps.size <= bulletProps.x ) { - BULLETS.splice(bulletIdx, 1); - ENEMIES.splice(enemyIdx, 1); + BULLETS.current.splice(bulletIdx, 1); + ENEMIES.current.splice(enemyIdx, 1); } } if (bulletProps.y <= 0) { - BULLETS.splice(bulletIdx, 1); + BULLETS.current.splice(bulletIdx, 1); } bullet.update(); @@ -201,11 +219,17 @@ export function Game() { } }; - const { ref } = useCanvas({ - setup, - draw, - options: { height: 600, width: 350 }, + const { ref, time, context, height, width, reset } = useCanvas({ + onInit, + onUpdate, + height: 600, + width: 350, }); - return ; + return ( +
+

Controls: Arrows + a

+ +
+ ); } diff --git a/example/app/08-math.js b/example/components/examples/Math.js similarity index 100% rename from example/app/08-math.js rename to example/components/examples/Math.js diff --git a/example/components/examples/Simple.js b/example/components/examples/Simple.js new file mode 100644 index 0000000..7f80de9 --- /dev/null +++ b/example/components/examples/Simple.js @@ -0,0 +1,65 @@ +"use client"; + +import { useRef } from "react"; +import { useCanvas } from "@kirkegaard/react-use-canvas"; +import { useBoundingBox } from "hooks/useBoundingBox"; + +export const randomFloat = (min, max) => { + return Math.random() * (max - min) + min; +}; + +export function Simple() { + const viewportRef = useRef(null); + const bounds = useBoundingBox({ ref: viewportRef }); + + const frameTime = useRef(1); + const lastTime = useRef(1); + + const fps = useRef(0); + const radius = 20; + + const onInit = () => {}; + + const onUpdate = () => { + frameTime.current = time - lastTime.current; + lastTime.current = time; + + context.clearRect(0, 0, width, height); + + context.fillStyle = "white"; + + context.save(); + context.translate(width / 2, height / 2); + + const x = (Math.cos((time / 1000) * 2.25) * (radius * 2 - width)) / 2; + const y = (Math.sin((time / 1000) * 4.5) * (radius * 2 - height)) / 2; + + context.beginPath(); + context.arc(x, y, radius, 0, 2 * Math.PI); + context.fill(); + context.restore(); + }; + + const { ref, context, time, height, width } = useCanvas({ + onInit, + onUpdate, + // onComplete: () => ({ + // shouldRepeat: true, + // newStartAt: randomFloat(0, 2000), + // }), + // duration: 2000, + startAt: 0, + width: bounds.width, + height: 300, + updateInterval: 1 / 120, + }); + + return ( +
+
FPS: {Math.round(1000 / frameTime.current)}
+
Frame time: {frameTime.current.toFixed(4)}
+
Time: {(time / 1000).toFixed(2)}
+ +
+ ); +} diff --git a/example/app/04-webgl.js b/example/components/examples/Webgl.js similarity index 73% rename from example/app/04-webgl.js rename to example/components/examples/Webgl.js index aec6527..4274b56 100644 --- a/example/app/04-webgl.js +++ b/example/components/examples/Webgl.js @@ -1,5 +1,8 @@ "use client"; + +import { useRef } from "react"; import { useCanvas } from "@kirkegaard/react-use-canvas"; +import { useBoundingBox } from "hooks/useBoundingBox"; const vs = `#version 300 es @@ -37,7 +40,8 @@ void main() { for(float i = 0., e = 0., j = 0.; i++ < 35.0 * 1.0;) { p = d * j / 0.6; - p.z += 1.0 + u_time / 1. * 0.006; + // p.z += 1.0 + u_time / 10. * 0.006; + p.z += 1.0 + u_time / 1000.; p.xy -= PI; p = asin(sin(p / 2.) * .8) * 1.2; float sc = .4 * 0.65; @@ -115,10 +119,29 @@ function createUniform(gl, program, type, name) { } export function WebGL() { - let uniformTime = null; - let uniformResolution = null; + const viewportRef = useRef(null); + const bounds = useBoundingBox({ ref: viewportRef }); + const frameTime = useRef(1); + const lastTime = useRef(1); + + const uniformTime = useRef(null); + const uniformResolution = useRef(null); + + const { + ref, + time, + context: gl, + height, + width, + } = useCanvas({ + onInit, + onUpdate, + height: 500, + width: bounds.width, + contextType: "webgl2", + }); - const setup = ({ context: gl, width, height }) => { + function onInit() { const vertexShader = compileShader(gl, vs, gl.VERTEX_SHADER); const fragmentShader = compileShader(gl, fs, gl.FRAGMENT_SHADER); const program = createProgram(gl, vertexShader, fragmentShader); @@ -139,31 +162,34 @@ export function WebGL() { gl.vertexAttribPointer(positionAttributeLocation, 2, gl.FLOAT, false, 0, 0); gl.viewport(0, 0, width, height); - uniformTime = createUniform(gl, program, "1f", "u_time"); - uniformResolution = createUniform(gl, program, "2f", "u_resolution"); + uniformTime.current = createUniform(gl, program, "1f", "u_time"); + uniformResolution.current = createUniform( + gl, + program, + "2f", + "u_resolution" + ); gl.useProgram(program); gl.bindVertexArray(vao); - }; + } - const draw = ({ context: gl, time, width, height }) => { - uniformTime(time); - uniformResolution(width, height); - gl.drawArrays(gl.TRIANGLES, 0, 6); - }; + function onUpdate() { + frameTime.current = time - lastTime.current; + lastTime.current = time; - const { ref } = useCanvas({ - setup, - draw, - options: { - height: 500, - width: 355, - contextType: "webgl2", - contextAttributes: { - antialias: false, - }, - }, - }); + uniformTime.current(time); + uniformResolution.current(width, height); + gl.viewport(0, 0, width, height); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } - return ; + return ( +
+
FPS: {Math.round(1000 / frameTime.current)}
+
Frame time: {frameTime.current.toFixed(4)}
+
Time: {(time / 1000).toFixed(2)}
+ +
+ ); } diff --git a/example/hooks/useBoundingBox.js b/example/hooks/useBoundingBox.js new file mode 100644 index 0000000..122d2cc --- /dev/null +++ b/example/hooks/useBoundingBox.js @@ -0,0 +1,19 @@ +import { useEffect, useState } from "react"; + +export const useBoundingBox = ({ ref }) => { + const [bounds, setBounds] = useState({}); + + useEffect(() => { + const getBounds = () => { + const bounds = ref.current.getBoundingClientRect(); + setBounds(bounds); + }; + + getBounds(); + + window.addEventListener("resize", getBounds); + return () => window.removeEventListener("resize", getBounds); + }, [ref]); + + return bounds; +}; diff --git a/example/hooks/useWindowSize.js b/example/hooks/useWindowSize.js new file mode 100644 index 0000000..e066647 --- /dev/null +++ b/example/hooks/useWindowSize.js @@ -0,0 +1,19 @@ +import { useEffect, useState } from "react"; + +export const useWindowSize = () => { + const [size, setSize] = useState({}); + + useEffect(() => { + const getWindowSize = () => { + const { innerHeight: height, innerWidth: width } = window; + setSize({ height, width }); + }; + + getWindowSize(); + + window.addEventListener("resize", getWindowSize); + return () => window.removeEventListener("resize", getWindowSize); + }, [ref]); + + return size; +}; diff --git a/example/jsconfig.json b/example/jsconfig.json new file mode 100644 index 0000000..36aa1a4 --- /dev/null +++ b/example/jsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "baseUrl": "." + } +} diff --git a/example/app/globals.css b/example/styles/globals.css similarity index 53% rename from example/app/globals.css rename to example/styles/globals.css index 2a99b1d..246eea7 100644 --- a/example/app/globals.css +++ b/example/styles/globals.css @@ -17,76 +17,11 @@ 0.5px 5.9px 8.9px hsl(var(--shadow-color) / 0.13); --max-width: 1100px; - --border-radius: 12px; - --font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", - "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", - "Fira Mono", "Droid Sans Mono", "Courier New", monospace; - - --foreground-rgb: 0, 0, 0; - --background-start-rgb: 214, 219, 220; - --background-end-rgb: 255, 255, 255; - - --primary-glow: conic-gradient( - from 180deg at 50% 50%, - #16abff33 0deg, - #0885ff33 55deg, - #54d6ff33 120deg, - #0071ff33 160deg, - transparent 360deg - ); - --secondary-glow: radial-gradient( - rgba(255, 255, 255, 1), - rgba(255, 255, 255, 0) - ); - - --tile-start-rgb: 239, 245, 249; - --tile-end-rgb: 228, 232, 233; - --tile-border: conic-gradient( - #00000080, - #00000040, - #00000030, - #00000020, - #00000010, - #00000010, - #00000080 - ); - - --callout-rgb: 238, 240, 241; - --callout-border-rgb: 172, 175, 176; - --card-rgb: 180, 185, 188; - --card-border-rgb: 131, 134, 135; + --border-radius: 1rem; } @media (prefers-color-scheme: dark) { :root { - --foreground-rgb: 255, 255, 255; - --background-start-rgb: 100, 10, 10; - --background-end-rgb: 0, 0, 0; - - --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0)); - --secondary-glow: linear-gradient( - to bottom right, - rgba(1, 65, 255, 0), - rgba(1, 65, 255, 0), - rgba(1, 65, 255, 0.3) - ); - - --tile-start-rgb: 2, 13, 46; - --tile-end-rgb: 2, 5, 19; - --tile-border: conic-gradient( - #ffffff80, - #ffffff40, - #ffffff30, - #ffffff20, - #ffffff10, - #ffffff10, - #ffffff80 - ); - - --callout-rgb: 20, 20, 20; - --callout-border-rgb: 108, 108, 108; - --card-rgb: 100, 100, 100; - --card-border-rgb: 200, 200, 200; } } @@ -120,10 +55,9 @@ body { hsl(164deg 100% 11%) 100% ); - padding: 2rem 0.8rem; + padding: 2rem 1rem; display: flex; - justify-content: center; } h1, @@ -139,10 +73,32 @@ h1 { color: transparent; } +h2 { + font-size: 2.5rem; +} + +h3 { + font-size: 1.8rem; +} + p { margin: 1rem 0; } +main { + width: 100%; +} + +nav { + margin: 2rem 0; +} +nav li { + list-style: none; +} + +article { +} + section { margin-bottom: 2rem; padding: 1rem; @@ -151,7 +107,7 @@ section { box-shadow: var(--shadow-elevation-high); - border-radius: 1rem; + border-radius: var(--border-radius); } a { @@ -159,6 +115,11 @@ a { text-decoration: none; } +a:hover { + background-color: red; + color: white; +} + @media (prefers-color-scheme: dark) { html { color-scheme: dark; diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..9154cdd --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "target": "ES6" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/node_modules", "dist"] +} diff --git a/src/hook/index.js b/src/hook/index.js index b77d0fe..1c2e72b 100644 --- a/src/hook/index.js +++ b/src/hook/index.js @@ -1,101 +1,157 @@ "use client"; -import { useCallback, useRef, useEffect, useState } from "react"; +import { + useEffect, + useLayoutEffect, + useCallback, + useRef, + useState, +} from "react"; + +const useIsomorphicEffect = + typeof window === "undefined" ? useEffect : useLayoutEffect; + +export const useCanvas = ({ + duration, + isPlaying = true, + startAt = 0, + updateInterval = 1 / 120, // 120 fps + + onInit, + onUpdate, + onComplete, + + height = 250, + width = 250, + + contextType = "2d", +}) => { + const [time, setTime] = useState(startAt); + const [context, setContext] = useState(null); -export const useCanvas = ({ setup, draw, options = {} }) => { const canvasRef = useRef(null); - const animationFrameIdRef = useRef(null); - const previousDeltaRef = useRef(0); - const frameCountRef = useRef(0); - - const { - height = 250, - width = 250, - contextType = "2d", - contextAttributes = {}, - ...otherOptions - } = options; - - const [fps, setFPS] = useState(otherOptions.fps || 120); - const [isPaused, setIsPaused] = useState(false); - - const getContext = useCallback(() => { - const canvas = canvasRef.current; - return canvas ? canvas.getContext(contextType, contextAttributes) : null; - }, [canvasRef, contextType, contextAttributes]); - - const render = useCallback( - (currentDelta) => { - if (!isPaused) { - animationFrameIdRef.current = window.requestAnimationFrame(render); + const elapsedTimeRef = useRef(0); + const startAtRef = useRef(startAt); + const totalElapsedTimeRef = useRef(startAt * -1000); + const requestFrameRef = useRef(null); + const previousTimeRef = useRef(null); + const repeatTimeoutRef = useRef(null); + + const loop = useCallback( + (time) => { + if (previousTimeRef.current === null) { + previousTimeRef.current = time; + requestFrameRef.current = requestAnimationFrame(loop); + return; } - const delta = currentDelta - previousDeltaRef.current; + const deltaTime = time - previousTimeRef.current; + const currentElapsedTime = elapsedTimeRef.current + deltaTime; - if (delta < 1000 / fps) { - return; + previousTimeRef.current = time; + elapsedTimeRef.current = currentElapsedTime; + + const currentDisplayTime = + startAtRef.current + + (updateInterval === 0 + ? currentElapsedTime + : ((currentElapsedTime / updateInterval) | 0) * updateInterval); + + const totalTime = startAtRef.current + currentElapsedTime; + const isCompleted = typeof duration === "number" && totalTime >= duration; + setTime(isCompleted ? duration : currentDisplayTime); + + if (!isCompleted) { + requestFrameRef.current = requestAnimationFrame(loop); } + }, + [duration, updateInterval] + ); - previousDeltaRef.current = currentDelta; - frameCountRef.current++; + const cleanup = () => { + requestFrameRef.current && cancelAnimationFrame(requestFrameRef.current); + repeatTimeoutRef.current && clearTimeout(repeatTimeoutRef.current); + previousTimeRef.current = null; + }; + + const reset = useCallback( + (newStartAt) => { + cleanup(); - const context = getContext(); + elapsedTimeRef.current = 0; + const nextStartAt = typeof newStartAt === "number" ? newStartAt : startAt; + startAtRef.current = nextStartAt; - if (context && typeof draw === "function") { - draw({ - context, - time: frameCountRef.current, - height, - width, - fps, - isPaused, - }); + setTime(nextStartAt); + + if (isPlaying) { + requestFrameRef.current = window.requestAnimationFrame(loop); } }, - [draw, getContext, fps, isPaused, height, width] + [isPlaying, startAt, loop] ); - useEffect(() => { + useIsomorphicEffect(() => { + if (context && typeof onInit === "function") { + onInit(); + } + }, [context]); + + useIsomorphicEffect(() => { if (canvasRef.current) { - const context = getContext(); - if (context) { - const { canvas } = context; + const context = canvasRef.current.getContext(contextType); + if (context) { if (contextType === "2d") { const ratio = window.devicePixelRatio || 1; - - canvas.setAttribute("width", width * ratio); - canvas.setAttribute("height", height * ratio); - + canvasRef.current.setAttribute("width", width * ratio); + canvasRef.current.setAttribute("height", height * ratio); context.scale(ratio, ratio); } else { - canvas.setAttribute("width", width); - canvas.setAttribute("height", height); + canvasRef.current.setAttribute("width", width); + canvasRef.current.setAttribute("height", height); } - canvas.style.width = width + "px"; - canvas.style.height = height + "px"; + canvasRef.current.style.width = `${width}px`; + canvasRef.current.style.height = `${height}px`; - if (typeof setup === "function") { - setup({ context, height, width }); - } - render(); + setContext(context); } else { - console.error( - `Unable to get context of type "${contextType}". Is webgl enabled?` + console.error("Could not get context"); + } + } + }, [canvasRef, height, width, contextType]); + + useIsomorphicEffect(() => { + if (context && typeof onUpdate === "function") { + onUpdate(); + } + + if (duration && time >= duration) { + totalElapsedTimeRef.current += duration * 1000; + + const { + shouldRepeat = false, + delay = 0, + newStartAt, + } = onComplete?.(totalElapsedTimeRef.current / 1000) || {}; + + if (shouldRepeat) { + repeatTimeoutRef.current = setTimeout( + () => reset(newStartAt), + delay * 1000 ); } } + }, [context, time, duration, onComplete, onUpdate, reset]); - return () => window.cancelAnimationFrame(animationFrameIdRef.current); - }, [canvasRef, contextType, height, width, getContext, setup, draw, render]); + useIsomorphicEffect(() => { + if (isPlaying) { + requestFrameRef.current = window.requestAnimationFrame(loop); + } - return { - ref: canvasRef, - pause: () => setIsPaused(!isPaused), - isPaused, - setIsPaused, - fps, - setFPS, - }; + return cleanup; + }, [loop, isPlaying, duration, updateInterval]); + + return { ref: canvasRef, time, reset, context, height, width }; };