├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .eslintrc.js ├── .gitignore ├── Grid2D.ts ├── README.md ├── fbm2d.ts ├── images ├── github-header-fs8.png ├── github-header.png ├── screenshot-fs8.png ├── screenshot.jpg └── screenshot.png ├── index.html ├── main.ts ├── music └── Voxel Revolution.mp3 ├── package-lock.json ├── package.json ├── release.sh ├── renderer.ts ├── styles ├── RacingSansOne.woff2 ├── github.svg ├── help.svg ├── style.css ├── volume_off.svg └── volume_up.svg ├── tsconfig.json └── volumeControls.ts /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.191.1/containers/typescript-node/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Node.js version: 16, 14, 12 4 | ARG VARIANT="16-buster" 5 | FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT} 6 | 7 | # [Optional] Uncomment this section to install additional OS packages. 8 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 9 | # && apt-get -y install --no-install-recommends 10 | 11 | # [Optional] Uncomment if you want to install an additional version of node using nvm 12 | # ARG EXTRA_NODE_VERSION=10 13 | # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 14 | 15 | # [Optional] Uncomment if you want to install more global node packages 16 | # RUN su node -c "npm install -g " 17 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.191.1/containers/typescript-node 3 | { 4 | "name": "Node.js & TypeScript", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | // Update 'VARIANT' to pick a Node version: 12, 14, 16 8 | "args": { 9 | "VARIANT": "16" 10 | } 11 | }, 12 | // Set *default* container specific settings.json values on container create. 13 | "settings": {}, 14 | // Add the IDs of extensions you want installed when the container is created. 15 | "extensions": [ 16 | "dbaeumer.vscode-eslint" 17 | ], 18 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 19 | // "forwardPorts": [], 20 | // Use 'postCreateCommand' to run commands after the container is created. 21 | // "postCreateCommand": "yarn install", 22 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 23 | // "remoteUser": "node" 24 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: [ 5 | '@typescript-eslint', 6 | ], 7 | env: { 8 | 'browser': true, 9 | 'node': true, 10 | 'mocha': true, 11 | 'es2017': true 12 | }, 13 | 'extends': ['eslint:recommended','plugin:@typescript-eslint/recommended'], 14 | 'globals': { 15 | }, 16 | 'parserOptions': { 17 | 'sourceType': 'module' 18 | }, 19 | 'rules': { 20 | 'indent': [ 21 | 'error', 22 | 2 23 | ], 24 | 'linebreak-style': [ 25 | 'error', 26 | 'unix' 27 | ], 28 | 'quotes': [ 29 | 'error', 30 | 'single' 31 | ], 32 | 'semi': [ 33 | 'error', 34 | 'always' 35 | ] 36 | }, 37 | 'ignorePatterns': [ 38 | '/dist' 39 | ] 40 | }; 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.parcel-cache 2 | node_modules 3 | /dist 4 | TODO -------------------------------------------------------------------------------- /Grid2D.ts: -------------------------------------------------------------------------------- 1 | export class Grid2D { 2 | readonly points: Float32Array; 3 | constructor(readonly width: number, readonly height: number, readonly components: number) { 4 | this.points = new Float32Array(width * height * components); 5 | } 6 | } 7 | export function iterateGrid2D(grid: Grid2D, callback: (points: Float32Array, x: number, y: number, i: number) => void) { 8 | const { width, points, components } = grid; 9 | const height = grid.points.length / width / components; 10 | for (let y = 0; y < height; y++) { 11 | for (let x = 0; x < width; x++) { 12 | const i = (y * width + x) * components; 13 | callback(points, x, y, i); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Header](./images/github-header-fs8.png) 2 | 3 | This is just a little canvas 2d demo for [simplex-noise.js](https://github.com/jwagner/simplex-noise.js). 4 | 5 | [View the demo](https://29a.ch/sandbox/2022/simplex-noise-synthwave/) or look at [main.ts](./main.ts) and [renderer.ts](./renderer.ts) to start digging through the code. 6 | 7 | -------------------------------------------------------------------------------- /fbm2d.ts: -------------------------------------------------------------------------------- 1 | import { NoiseFunction2D } from 'simplex-noise'; 2 | 3 | export function fbm2d(noise2D: NoiseFunction2D, octaves: number): NoiseFunction2D { 4 | return function fbm2dFn(x: number, y: number) { 5 | let value = 0.0; 6 | let amplitude = 0.5; 7 | for (let i = 0; i < octaves; i++) { 8 | value += noise2D(x, y) * amplitude; 9 | x *= 0.5; 10 | y *= 0.5; 11 | amplitude *= 0.8; 12 | } 13 | return value; 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /images/github-header-fs8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwagner/simplex-noise-demo-synthwave/13a5d96bada482b44a323c3b1ea6087102cb0ee2/images/github-header-fs8.png -------------------------------------------------------------------------------- /images/github-header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwagner/simplex-noise-demo-synthwave/13a5d96bada482b44a323c3b1ea6087102cb0ee2/images/github-header.png -------------------------------------------------------------------------------- /images/screenshot-fs8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwagner/simplex-noise-demo-synthwave/13a5d96bada482b44a323c3b1ea6087102cb0ee2/images/screenshot-fs8.png -------------------------------------------------------------------------------- /images/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwagner/simplex-noise-demo-synthwave/13a5d96bada482b44a323c3b1ea6087102cb0ee2/images/screenshot.jpg -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwagner/simplex-noise-demo-synthwave/13a5d96bada482b44a323c3b1ea6087102cb0ee2/images/screenshot.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Simplex Noise Synth Wave Demo 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |

simplex-noise.js

18 | 19 |
20 | 21 |
22 |
23 | 25 | 27 |
28 |
Music "Voxel Revolution" Kevin MacLeod 29 | (incompetech.com) 30 | Licensed under Creative Commons: By Attribution 4.0 31 | License
32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | 2 | import { volumeControls } from './volumeControls'; 3 | import { renderer } from './renderer'; 4 | 5 | 6 | let freezeTime = 0; 7 | 8 | function hashchange() { 9 | const freezeMatch = location.hash.match(/freeze=(\d+)/); 10 | freezeTime = freezeMatch ? +freezeMatch[1] * 1000 : 0; 11 | } 12 | window.addEventListener('hashchange', hashchange); 13 | hashchange(); 14 | 15 | 16 | function mainloop(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) { 17 | function createRenderer() { 18 | const canvasRect = canvas.getBoundingClientRect(); 19 | canvas.width = Math.max(canvasRect.width, window.innerWidth) | 0; 20 | canvas.height = Math.max(canvasRect.height, window.innerHeight) | 0; 21 | 22 | return renderer(ctx, canvas.width, canvas.height); 23 | } 24 | 25 | let render = createRenderer(); 26 | 27 | window.addEventListener('resize', () => { 28 | render = createRenderer(); 29 | }); 30 | 31 | const start = performance.now(); 32 | 33 | function tick() { 34 | const t = freezeTime ? freezeTime : performance.now() - start; 35 | render(t); 36 | requestAnimationFrame(tick); 37 | } 38 | requestAnimationFrame(tick); 39 | } 40 | 41 | const canvas = document.getElementById('canvas'); 42 | if (!(canvas instanceof HTMLCanvasElement)) throw new Error('c is not a canvas'); 43 | 44 | const ctx = canvas.getContext('2d', { alpha: false }); 45 | if (ctx === null) throw new Error('canvas does not support context 2d'); 46 | 47 | mainloop(canvas, ctx); 48 | 49 | const music = document.getElementById('music'); 50 | if (!(music instanceof HTMLAudioElement)) throw new Error('music is not audio'); 51 | const volume = document.getElementById('volume'); 52 | if (!(volume instanceof HTMLDivElement)) throw new Error('volume is not div'); 53 | 54 | volumeControls( 55 | music, 56 | volume 57 | ); -------------------------------------------------------------------------------- /music/Voxel Revolution.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwagner/simplex-noise-demo-synthwave/13a5d96bada482b44a323c3b1ea6087102cb0ee2/music/Voxel Revolution.mp3 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simplex-noise-demo-synthwave", 3 | "homepage": "https://29a.ch/sandbox/2022/simplex-noise-synthwave/", 4 | "version": "1.0.0", 5 | "description": "", 6 | "browserslist": "> 0.5%, last 2 versions, not dead", 7 | "scripts": { 8 | "start": "rm -rf dist && parcel index.html", 9 | "build": "npx parcel build index.html --dist-dir dist/static/ --public-url static/ && mv dist/static/*.html dist/", 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "author": "", 13 | "license": "UNLICENSED", 14 | "devDependencies": { 15 | "@typescript-eslint/eslint-plugin": "^5.30.6", 16 | "@typescript-eslint/parser": "^5.30.6", 17 | "alea": "^1.0.1", 18 | "eslint": "^8.19.0", 19 | "eslint-config-standard": "^17.0.0", 20 | "eslint-plugin-import": "^2.26.0", 21 | "parcel": "^2.6.2" 22 | }, 23 | "dependencies": { 24 | "gl-matrix": "^3.4.3", 25 | "simplex-noise": "^4.0.0" 26 | } 27 | } -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # a bit of a hacky to store resources under static/ 3 | set -e 4 | shopt -s globstar 5 | HOMEPAGE_PATH=$(jq -r '.homepage | sub("https://29a.ch/";"")' package.json) 6 | DEST="x.29a.ch:/var/www/static/$HOMEPAGE_PATH" 7 | 8 | find dist -iname *.map -delete 9 | gzip -fk dist/**/*.{js,css,html,svg} 10 | 11 | rsync -rv dist/static/ "${DEST}static/" 12 | rsync -rv dist/ "$DEST" 13 | -------------------------------------------------------------------------------- /renderer.ts: -------------------------------------------------------------------------------- 1 | import { vec3, mat4 } from 'gl-matrix'; 2 | import { Grid2D, iterateGrid2D } from './Grid2D'; 3 | import { createNoise2D } from 'simplex-noise'; 4 | import { fbm2d } from './fbm2d'; 5 | import alea from 'alea'; 6 | 7 | const rng = alea('B'); 8 | const simplexNoise2D = createNoise2D(rng); 9 | const noise2D = fbm2d(simplexNoise2D, 2); 10 | 11 | type Render = (t: number) => void; 12 | export function renderer(ctx: CanvasRenderingContext2D, width: number, height: number): Render { 13 | 14 | const skyGradient = ctx.createLinearGradient(0, 0, 0, height / 4 * 3); 15 | skyGradient.addColorStop(0.0, '#1c014add'); 16 | skyGradient.addColorStop(0.5, '#d40485dd'); 17 | skyGradient.addColorStop(0.71, '#fd9554ee'); 18 | skyGradient.addColorStop(1.0, '#000000ee'); 19 | 20 | function renderSky() { 21 | ctx.fillStyle = skyGradient; 22 | ctx.fillRect(0, 0, width, height / 4 * 3); 23 | } 24 | 25 | const maxGridWidth = 64; 26 | const minGridWidth = 4; 27 | const gridWidth = Math.max(minGridWidth, Math.min((width / 16) | 0, maxGridWidth)); 28 | const maxGridHeight = 64; 29 | const minGridHeight = 32; 30 | const gridHeight = Math.max(minGridHeight, Math.min((height / 16) | 0, maxGridHeight)); 31 | 32 | const grid = new Grid2D(gridWidth, gridHeight, 3); 33 | 34 | function updateTerrain(t: number) { 35 | const gridPoint = vec3.create(); 36 | iterateGrid2D(grid, (p, x, y, i) => { 37 | const speed = 0.02; //2; 38 | const terrainOffset = Math.floor(t * speed); 39 | const roadWidth = 0.03; 40 | const twistynessPeriod = 210; 41 | const roadTwistyness = 6 * Math.max(0, Math.sin((y - terrainOffset) / twistynessPeriod)); 42 | const cornerPeriod = 10; 43 | const roadWinding = Math.sin( 44 | (y - terrainOffset) / cornerPeriod 45 | ) * roadTwistyness; 46 | const road = Math.max( 47 | roadWidth - 1, 48 | -Math.cos(((x + roadWinding) / grid.width - 0.5) * Math.PI * 2) 49 | ) + 1; 50 | const noiseScale = 0.15; 51 | const hillinessPeriod = 220; 52 | const hilliness = Math.abs(Math.sin((y - terrainOffset) / hillinessPeriod)); 53 | const mountainHeight = 5; 54 | const mountainOffset = 2; 55 | const mountains = mountainOffset + noise2D(x * noiseScale, (y - terrainOffset) * noiseScale) * mountainHeight * hilliness; 56 | const elevation = road * mountains; 57 | vec3.set(gridPoint, 58 | -grid.width / 2 + x, 59 | -5 + elevation, 60 | 5 + (grid.height - y) - t * speed % 1 61 | ); 62 | vec3.transformMat4(gridPoint, gridPoint, projectionMatrix); 63 | // convert from ndc to pixel coordinates 64 | gridPoint[0] = (1 + gridPoint[0]) / 2 * width; 65 | gridPoint[1] = (1 + gridPoint[1]) / 2 * height; 66 | p[i] = gridPoint[0]; 67 | p[i + 1] = gridPoint[1]; 68 | p[i + 2] = (grid.height - y + (terrainOffset % 1)) / grid.height; 69 | }); 70 | } 71 | 72 | const projectionMatrix = mat4.perspective(mat4.create(), 45, width / height, 0.1, 200); 73 | function drawGrid2D() { 74 | const { width: gridWidth, height: gridHeight, points, components: gridComponents } = grid; 75 | 76 | function index(x: number, y: number) { 77 | return (y * gridWidth + x) * gridComponents; 78 | } 79 | 80 | function moveTo(x: number, y: number) { 81 | const i = index(x, y); 82 | // Not quite sure why but rounding here saves ~2ms/frame 83 | ctx.moveTo(points[i], points[i + 1]); 84 | } 85 | 86 | function lineTo(x: number, y: number) { 87 | const i = index(x, y); 88 | ctx.lineTo(points[i], points[i + 1]); 89 | } 90 | 91 | function isInvisible(x: number, y: number) { 92 | const vertices = [ 93 | index(x, y), 94 | index(x + 1, y), 95 | index(x + 1, y + 1), 96 | index(x, y + 1), 97 | ]; 98 | 99 | // not on screen 100 | if ( 101 | !vertices.some(v => points[v] >= 0) || 102 | !vertices.some(v => points[v] <= width) || 103 | !vertices.some(v => points[v + 1] >= 0) || 104 | !vertices.some(v => points[v + 1] <= height) 105 | ) { 106 | return true; 107 | } 108 | 109 | 110 | const ax = points[vertices[1]] - points[vertices[0]]; 111 | const ay = -points[vertices[1] + 1] - -points[vertices[0] + 1]; 112 | const bx = points[vertices[3]] - points[vertices[0]]; 113 | const by = -points[vertices[3] + 1] - -points[vertices[0] + 1]; 114 | const cx = points[vertices[2]] - points[vertices[0]]; 115 | const cy = -points[vertices[2] + 1] - -points[vertices[0] + 1]; 116 | 117 | const magnitudeACrossB = (ax * by) - (ay * bx); 118 | const magnitudeACrossC = (ax * cy) - (ay * cx); 119 | 120 | if (magnitudeACrossB < 0 && magnitudeACrossC < 0) return true; 121 | 122 | return false; 123 | } 124 | 125 | ctx.strokeStyle = 'purple'; 126 | ctx.fillStyle = '#03020afa'; 127 | 128 | for (let y = 0; y < gridHeight - 1; y++) { 129 | const z = points[index(0, y) + 2]; 130 | const fadeDistance = 0.8; 131 | ctx.globalAlpha = 1 - Math.max(0, z - fadeDistance) * (1 / (1 - fadeDistance)); 132 | for (let x = 0; x < gridWidth - 1; x++) { 133 | // culling quads in javascript seems to be worth it 134 | if (isInvisible(x, y)) { 135 | continue; 136 | } 137 | 138 | // drawing each rect individually is faster and avoids self intersection issues 139 | ctx.beginPath(); 140 | moveTo(x, y); 141 | lineTo(x + 1, y); 142 | lineTo(x + 1, y + 1); 143 | lineTo(x, y + 1); 144 | ctx.closePath(); 145 | ctx.fill(); 146 | ctx.stroke(); 147 | } 148 | } 149 | } 150 | 151 | return function render(t: number) { 152 | renderSky(); 153 | 154 | updateTerrain(t); 155 | 156 | drawGrid2D(); 157 | }; 158 | } 159 | 160 | -------------------------------------------------------------------------------- /styles/RacingSansOne.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwagner/simplex-noise-demo-synthwave/13a5d96bada482b44a323c3b1ea6087102cb0ee2/styles/RacingSansOne.woff2 -------------------------------------------------------------------------------- /styles/github.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /styles/help.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /styles/style.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | position: relative; 4 | width: 100vw; 5 | height: 100vh; 6 | margin: 0; 7 | background: #000; 8 | } 9 | 10 | #canvas { 11 | display: block; 12 | height: 100vh; 13 | width: 100vw; 14 | } 15 | 16 | #logo { 17 | width: 1920px; 18 | position: absolute; 19 | text-align: center; 20 | bottom: 65%; 21 | line-height: 0; 22 | width: 100%; 23 | font-family: 'Racing Sans One'; 24 | font-weight: 400; 25 | font-size: 9vw; 26 | color: #fff; 27 | text-shadow: #00d9ff 0 0 32px; 28 | mix-blend-mode: screen; 29 | } 30 | 31 | #attribution { 32 | position: absolute; 33 | bottom: 0; 34 | right: 0; 35 | padding: 1em; 36 | color: #ccc; 37 | font-family: sans-serif; 38 | font-size: 12px; 39 | } 40 | 41 | a { 42 | color: #fff; 43 | } 44 | 45 | #controls { 46 | position: absolute; 47 | top: 0; 48 | right: 0; 49 | } 50 | 51 | #controls>.control { 52 | display: block; 53 | margin: 1em; 54 | width: 48px; 55 | height: 48px; 56 | filter: invert() drop-shadow(0 0 4px #fff); 57 | background-size: 100%; 58 | cursor: pointer; 59 | opacity: 0.5; 60 | } 61 | 62 | #controls>.control:hover { 63 | opacity: 1.0; 64 | } 65 | 66 | #volume { 67 | background-image: url(volume_up.svg); 68 | 69 | /* background-color: white; */ 70 | background-size: cover; 71 | cursor: pointer; 72 | /* not quite sure why but having any animations active wrecks performance. Might be because of presenting partial frames. */ 73 | /* animation: pulse 1s ease-in-out 0; */ 74 | } 75 | 76 | #github { 77 | background-image: url(github.svg); 78 | } 79 | 80 | #about { 81 | background-image: url(help.svg); 82 | } 83 | 84 | #volume.on { 85 | background-image: url(volume_off.svg); 86 | } 87 | 88 | /* latin */ 89 | @font-face { 90 | font-family: 'Racing Sans One'; 91 | font-style: normal; 92 | font-weight: 400; 93 | src: url(./RacingSansOne.woff2) format('woff2'); 94 | } -------------------------------------------------------------------------------- /styles/volume_off.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /styles/volume_up.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2019", 5 | "declaration": true, 6 | "strict": true, 7 | "resolveJsonModule": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "jsx": "react" 11 | }, 12 | "exclude": [ 13 | "dist", 14 | "test" 15 | ] 16 | } -------------------------------------------------------------------------------- /volumeControls.ts: -------------------------------------------------------------------------------- 1 | export function volumeControls(audio: HTMLAudioElement, control: HTMLDivElement) { 2 | function setControlClass() { 3 | if (!audio.paused) { 4 | control.className = 'control on'; 5 | } 6 | else { 7 | control.className = 'control'; 8 | } 9 | } 10 | setControlClass(); 11 | audio.addEventListener('playing', () => { 12 | setControlClass(); 13 | }); 14 | audio.addEventListener('pause', () => { 15 | setControlClass(); 16 | }); 17 | control.addEventListener('click', () => { 18 | if (audio.paused) { 19 | audio.play(); 20 | } else { 21 | audio.pause(); 22 | } 23 | }); 24 | } 25 | --------------------------------------------------------------------------------