From 3cb8d5a14e33dc8ef16f4d48b8d404925332212a Mon Sep 17 00:00:00 2001 From: John Gatward Date: Wed, 18 Mar 2026 15:24:08 +0000 Subject: [PATCH] tsp --- index.html | 21 +- projects/flocking/index.html | 78 ++++++ projects/flocking/script.js | 241 ++++++++++++++++++ projects/flocking/style.css | 239 +++++++++++++++++ projects/thin_ice/script.js | 101 ++++---- projects/thin_ice/style.css | 436 +++++++++++++++----------------- projects/thin_ice/thin_ice.html | 85 ++++--- projects/tsp/canvas.js | 19 ++ projects/tsp/index.html | 74 ++++++ projects/tsp/main.js | 245 ++++++++++++++++++ projects/tsp/main.wasm | Bin 0 -> 3491 bytes projects/tsp/point.js | 6 + projects/tsp/shaders.js | 20 ++ projects/tsp/style.css | 202 +++++++++++++++ projects/tsp/tsp-gl.js | 46 ++++ projects/tsp/utils.js | 19 ++ projects/tsp/wasm.js | 24 ++ 17 files changed, 1525 insertions(+), 331 deletions(-) create mode 100644 projects/flocking/index.html create mode 100644 projects/flocking/script.js create mode 100644 projects/flocking/style.css create mode 100644 projects/tsp/canvas.js create mode 100644 projects/tsp/index.html create mode 100644 projects/tsp/main.js create mode 100755 projects/tsp/main.wasm create mode 100644 projects/tsp/point.js create mode 100644 projects/tsp/shaders.js create mode 100644 projects/tsp/style.css create mode 100644 projects/tsp/tsp-gl.js create mode 100644 projects/tsp/utils.js create mode 100644 projects/tsp/wasm.js diff --git a/index.html b/index.html index 54f0ab5..8d80ad1 100644 --- a/index.html +++ b/index.html @@ -228,17 +228,30 @@ A small tile game created inspired by a childhood game. + + 20 Mar 2024 + Travelling Salesman Problem + A* Pathfinding algorithm, backend in zig, rendered with WebGL. + + + + 31 Jan 2024 + Flocking + A flocking simulator, inspired by The Coding Train's video. + + - 23 Jul 2023 + 21 Jul 2023 Game of Life After previously implementing game of life in p5.js, I implemented a generic simulator, for more obsquere automata. - 2 Feb 2022 + 5 Feb 2022 Percolation - A small demonstration to show liquid percolating through a medium as more cracks appear - and at what p value this happens. + A small demonstration to show liquid percolating through a medium as more cracks + appear - and at what p value this happens. @@ -249,7 +262,7 @@ - 23 Sep 2021 + 17 Sep 2021 2D Marching squares Marching squares in action — click a tile to edit the terrain. diff --git a/projects/flocking/index.html b/projects/flocking/index.html new file mode 100644 index 0000000..e9e4866 --- /dev/null +++ b/projects/flocking/index.html @@ -0,0 +1,78 @@ + + + + + + Flocking Boids - WASM + + + +
+ + +
+ + + + + diff --git a/projects/flocking/script.js b/projects/flocking/script.js new file mode 100644 index 0000000..a970424 --- /dev/null +++ b/projects/flocking/script.js @@ -0,0 +1,241 @@ +import init, { SimulationWasm } from './pkg/flocking.js'; + +const BOIDS_PER_1920X1080 = 250; +const MIN_INITIAL_BOIDS = 100; +const MAX_INITIAL_BOIDS = 1000; +const BOID_STEP = 10; + +let wasm = null; +let sim = null; +let canvas = null; +let ctx = null; +let controls = null; +let controlsToggle = null; +let controlsResizeObserver = null; +let frameCount = 0; +let lastFpsUpdate = Date.now(); +let displayWidth = 1; +let displayHeight = 1; +let resizeAnimationFrame = 0; + +function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); +} + +function getInitialBoidCount(width, height) { + const areaScale = (width * height) / (1920 * 1080); + const targetCount = Math.round(BOIDS_PER_1920X1080 * areaScale); + const clampedCount = clamp(targetCount, MIN_INITIAL_BOIDS, MAX_INITIAL_BOIDS); + return Math.round(clampedCount / BOID_STEP) * BOID_STEP; +} + +function syncBoidCountToViewport(width, height) { + const targetCount = getInitialBoidCount(width, height); + let currentCount = sim.get_boid_count(); + + while (currentCount < targetCount) { + sim.add_boid(); + currentCount = sim.get_boid_count(); + } + + while (currentCount > targetCount) { + sim.remove_boid(); + currentCount = sim.get_boid_count(); + } + + updateBoidCount(); +} + +function updateControlsCollapsedState(isCollapsed) { + if (controls === null || controlsToggle === null) { + return; + } + + controls.classList.toggle('collapsed', isCollapsed); + controls.setAttribute('aria-expanded', String(!isCollapsed)); + controlsToggle.setAttribute('aria-expanded', String(!isCollapsed)); + controlsToggle.setAttribute('aria-label', isCollapsed ? 'Expand controls' : 'Collapse controls'); + controlsToggle.textContent = isCollapsed ? '‹' : '›'; +} + +function sizeCanvasToViewport() { + const dpr = window.devicePixelRatio || 1; + const nextDisplayWidth = Math.max(1, Math.floor(canvas.clientWidth)); + const nextDisplayHeight = Math.max(1, Math.floor(canvas.clientHeight)); + const nextBufferWidth = Math.max(1, Math.round(nextDisplayWidth * dpr)); + const nextBufferHeight = Math.max(1, Math.round(nextDisplayHeight * dpr)); + + if (canvas.width !== nextBufferWidth || canvas.height !== nextBufferHeight) { + canvas.width = nextBufferWidth; + canvas.height = nextBufferHeight; + } + + displayWidth = nextDisplayWidth; + displayHeight = nextDisplayHeight; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + document.getElementById('canvasSize').textContent = `${displayWidth}x${displayHeight}`; + + return { width: displayWidth, height: displayHeight }; +} + +function scheduleResize() { + if (resizeAnimationFrame !== 0) { + return; + } + + resizeAnimationFrame = window.requestAnimationFrame(() => { + resizeAnimationFrame = 0; + const { width, height } = sizeCanvasToViewport(); + if (sim !== null) { + sim.resize(width, height); + syncBoidCountToViewport(width, height); + } + }); +} + +function setupResizeObserver() { + controlsResizeObserver = new ResizeObserver(() => { + scheduleResize(); + }); + controlsResizeObserver.observe(document.getElementById('container')); + controlsResizeObserver.observe(controls); +} + +async function run() { + wasm = await init(); + + canvas = document.getElementById('canvas'); + ctx = canvas.getContext('2d'); + controls = document.getElementById('controls'); + controlsToggle = document.getElementById('controlsToggle'); + updateControlsCollapsedState(false); + + const { width, height } = sizeCanvasToViewport(); + sim = new SimulationWasm(width, height, getInitialBoidCount(width, height)); + + setupControls(); + setupResizeObserver(); + updateBoidCount(); + window.addEventListener('resize', scheduleResize); + + animate(); +} + +function setupControls() { + const alignSlider = document.getElementById('alignSlider'); + const cohesionSlider = document.getElementById('cohesionSlider'); + const separationSlider = document.getElementById('separationSlider'); + const addBtn = document.getElementById('addBtn'); + const removeBtn = document.getElementById('removeBtn'); + + controlsToggle.addEventListener('click', () => { + const isCollapsed = !controls.classList.contains('collapsed'); + updateControlsCollapsedState(isCollapsed); + scheduleResize(); + }); + + alignSlider.addEventListener('input', (e) => { + const value = Number.parseFloat(e.target.value); + sim.set_align_mult(value); + document.getElementById('alignValue').textContent = value.toFixed(2); + }); + + cohesionSlider.addEventListener('input', (e) => { + const value = Number.parseFloat(e.target.value); + sim.set_cohesion_mult(value); + document.getElementById('cohesionValue').textContent = value.toFixed(2); + }); + + separationSlider.addEventListener('input', (e) => { + const value = Number.parseFloat(e.target.value); + sim.set_separation_mult(value); + document.getElementById('separationValue').textContent = value.toFixed(2); + }); + + addBtn.addEventListener('click', () => { + sim.add_boid(); + updateBoidCount(); + }); + + removeBtn.addEventListener('click', () => { + sim.remove_boid(); + updateBoidCount(); + }); +} + +function updateBoidCount() { + document.getElementById('boidCount').textContent = sim.get_boid_count(); +} + +function getBoidView() { + const ptr = sim.boid_buffer_ptr(); + const len = sim.boid_buffer_len(); + return new Float32Array(wasm.memory.buffer, ptr, len); +} + +function animate() { + ctx.fillStyle = '#121212'; + ctx.fillRect(0, 0, displayWidth, displayHeight); + + sim.step(); + const boidData = getBoidView(); + const stride = sim.boid_buffer_stride(); + + for (let i = 0; i < boidData.length; i += stride) { + const x = boidData[i]; + const y = boidData[i + 1]; + const vx = boidData[i + 2]; + const vy = boidData[i + 3]; + const r = boidData[i + 4]; + const g = boidData[i + 5]; + const b = boidData[i + 6]; + const a = boidData[i + 7]; + + const speedSq = vx * vx + vy * vy; + let dx; + let dy; + if (speedSq > 1e-6) { + const invSpeed = 1 / Math.sqrt(speedSq); + dx = vx * invSpeed; + dy = vy * invSpeed; + } else { + dx = 0; + dy = -1; + } + + const size = 5; + const headMult = 3; + const px = -dy; + const py = dx; + + const tipX = x + dx * size * headMult; + const tipY = y + dy * size * headMult; + const baseX = x - dx * size; + const baseY = y - dy * size; + const leftX = baseX + px * size; + const leftY = baseY + py * size; + const rightX = baseX - px * size; + const rightY = baseY - py * size; + + ctx.strokeStyle = `rgba(${r},${g},${b},${a / 255})`; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(tipX, tipY); + ctx.lineTo(leftX, leftY); + ctx.lineTo(rightX, rightY); + ctx.closePath(); + ctx.stroke(); + } + + frameCount += 1; + const now = Date.now(); + if (now - lastFpsUpdate >= 1000) { + document.getElementById('fps').textContent = frameCount; + frameCount = 0; + lastFpsUpdate = now; + } + + requestAnimationFrame(animate); +} + +await run(); diff --git a/projects/flocking/style.css b/projects/flocking/style.css new file mode 100644 index 0000000..17e8e5d --- /dev/null +++ b/projects/flocking/style.css @@ -0,0 +1,239 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --panel-width: 280px; + --panel-collapsed-width: 44px; + --panel-transition: 180ms ease; +} + +html, body { + width: 100%; + height: 100%; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: #121212; + color: #d9d9d9; + overflow: hidden; +} + +#container { + display: flex; + width: 100vw; + height: 100vh; +} + +#canvas { + flex: 1; + min-width: 0; + min-height: 0; + display: block; + background: #121212; +} + +#controls { + width: var(--panel-width); + background: #1e1e1e; + border-left: 1px solid #333; + overflow: hidden; + display: flex; + flex-direction: column; + transition: width var(--panel-transition), min-width var(--panel-transition); + min-width: var(--panel-width); +} + +#controls.collapsed { + width: var(--panel-collapsed-width); + min-width: var(--panel-collapsed-width); +} + +#controlsHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 12px 12px 16px; + border-bottom: 1px solid #333; +} + +#controlsTitle { + font-size: 16px; + color: #d9d9d9; + white-space: nowrap; +} + +#controlsToggle { + width: 32px; + min-width: 32px; + height: 32px; + padding: 0; + border: 1px solid #333; + background: #2a2a2a; + color: #d9d9d9; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + line-height: 1; + transition: all 0.2s; +} + +#controlsToggle:hover { + background: #3a3a3a; + border-color: #4a9eff; +} + +#controlsBody { + flex: 1; + padding: 20px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 20px; + opacity: 1; + transition: opacity 120ms ease; +} + +#controls.collapsed #controlsBody { + opacity: 0; + pointer-events: none; +} + +#controls.collapsed #controlsTitle { + display: none; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 10px; +} + +label { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #a0a0a0; +} + +input[type="range"] { + width: 100%; + height: 6px; + border-radius: 3px; + background: #333; + outline: none; + -webkit-appearance: none; + appearance: none; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: #4a9eff; + cursor: pointer; + transition: background 0.2s; +} + +input[type="range"]::-webkit-slider-thumb:hover { + background: #2196f3; +} + +input[type="range"]::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: #4a9eff; + cursor: pointer; + border: none; + transition: background 0.2s; +} + +input[type="range"]::-moz-range-thumb:hover { + background: #2196f3; +} + +.value-display { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 14px; +} + +.value-display strong { + color: #4a9eff; + font-weight: 600; +} + +.button-group { + display: flex; + gap: 10px; +} + +button { + flex: 1; + padding: 10px 16px; + border: 1px solid #333; + background: #2a2a2a; + color: #d9d9d9; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all 0.2s; +} + +button:hover { + background: #3a3a3a; + border-color: #4a9eff; +} + +button:active { + background: #1a1a1a; +} + +.stats { + padding: 12px; + background: #252525; + border-radius: 4px; + border-left: 3px solid #4a9eff; + font-size: 13px; +} + +.stat-item { + display: flex; + justify-content: space-between; + margin-bottom: 6px; +} + +.stat-item:last-child { + margin-bottom: 0; +} + +.stat-label { + color: #a0a0a0; +} + +.stat-value { + color: #4a9eff; + font-weight: 600; + font-family: 'Courier New', monospace; +} + +#fps { + color: #4a9eff; + font-size: 13px; + font-family: 'Courier New', monospace; +} + +.divider { + height: 1px; + background: #333; +} diff --git a/projects/thin_ice/script.js b/projects/thin_ice/script.js index 421b944..e508488 100644 --- a/projects/thin_ice/script.js +++ b/projects/thin_ice/script.js @@ -1,32 +1,30 @@ -const statusElement = document.getElementById("status"); -const progressElement = document.getElementById("progress"); -const spinnerElement = document.getElementById("spinner"); -const canvasElement = document.getElementById("canvas"); -const outputElement = document.getElementById("output"); +const statusElement = document.getElementById('status'); +const progressElement = document.getElementById('progress'); +const canvasElement = document.getElementById('canvas'); +const outputElement = document.getElementById('output'); +const fullscreenButton = document.getElementById('fullscreenButton'); +const resizeCheckbox = document.getElementById('resize'); +const pointerLockCheckbox = document.getElementById('pointerLock'); if (outputElement) { - outputElement.value = ""; + outputElement.value = ''; } -canvasElement.addEventListener( - "webglcontextlost", - (event) => { - alert("WebGL context lost. You will need to reload the page."); - event.preventDefault(); - }, - false -); +canvasElement.addEventListener('webglcontextlost', (event) => { + event.preventDefault(); + setStatus('WebGL context lost. Reload the page to restart the game.'); +}, false); function setStatus(text) { if (!setStatus.last) { - setStatus.last = { time: Date.now(), text: "" }; + setStatus.last = { time: Date.now(), text: '' }; } if (text === setStatus.last.text) { return; } - const match = text.match(/([^(]+)\((\d+(\.\d+)?)\/(\d+)\)/); + const match = text?.match(/([^(]+)\((\d+(?:\.\d+)?)\/(\d+)\)/); const now = Date.now(); if (match && now - setStatus.last.time < 30) { @@ -37,58 +35,55 @@ function setStatus(text) { setStatus.last.text = text; if (match) { - statusElement.innerHTML = match[1].trim(); - progressElement.value = 100 * parseInt(match[2], 10); - progressElement.max = 100 * parseInt(match[4], 10); + statusElement.textContent = match[1].trim(); + progressElement.value = Number.parseInt(match[2], 10) * 100; + progressElement.max = Number.parseInt(match[3], 10) * 100; progressElement.hidden = false; - spinnerElement.hidden = false; } else { - statusElement.innerHTML = text; - progressElement.value = null; - progressElement.max = null; - progressElement.hidden = true; - + statusElement.textContent = text || ''; + progressElement.hidden = !text; if (!text) { - spinnerElement.style.display = "none"; + progressElement.removeAttribute('value'); } } } -var Module = window.Module || {}; -Module.print = (...args) => { +globalThis.Module = globalThis.Module || {}; +globalThis.Module.canvas = canvasElement; +globalThis.Module.print = (...args) => { console.log(...args); - if (!outputElement) { return; } - - const text = args.join(" "); - outputElement.value += text + "\n"; + outputElement.value += `${args.join(' ')}\n`; outputElement.scrollTop = outputElement.scrollHeight; }; -Module.canvas = canvasElement; -Module.setStatus = setStatus; -Module.totalDependencies = 0; -Module.monitorRunDependencies = function (left) { +globalThis.Module.printErr = (...args) => { + console.error(...args); + if (!outputElement) { + return; + } + outputElement.value += `[err] ${args.join(' ')}\n`; + outputElement.scrollTop = outputElement.scrollHeight; +}; +globalThis.Module.setStatus = setStatus; +globalThis.Module.totalDependencies = 0; +globalThis.Module.monitorRunDependencies = function (left) { this.totalDependencies = Math.max(this.totalDependencies, left); - setStatus( - left - ? "Preparing... (" + (this.totalDependencies - left) + "/" + this.totalDependencies + ")" - : "All downloads complete." - ); + setStatus(left ? `Preparing... (${this.totalDependencies - left}/${this.totalDependencies})` : 'Running...'); + if (!left) { + setTimeout(() => setStatus(''), 250); + } }; -setStatus("Downloading..."); +fullscreenButton.addEventListener('click', () => { + if (typeof globalThis.Module.requestFullscreen === 'function') { + globalThis.Module.requestFullscreen(pointerLockCheckbox.checked, resizeCheckbox.checked); + } +}); -window.onerror = (message) => { - setStatus("Exception thrown, see JavaScript console"); - spinnerElement.style.display = "none"; - setStatus = (text) => { - if (text) { - console.error("[post-exception status] " + text); - } - }; - return message; +setStatus('Downloading...'); + +globalThis.onerror = () => { + setStatus('Exception thrown, see JavaScript console'); }; - -window.Module = Module; diff --git a/projects/thin_ice/style.css b/projects/thin_ice/style.css index 0af71a3..530ce80 100644 --- a/projects/thin_ice/style.css +++ b/projects/thin_ice/style.css @@ -1,295 +1,259 @@ -:root { - --bg-0: #0b1220; - --bg-1: #13213a; - --bg-2: #1b2f4d; - --ice: #cfeeff; - --snow: #f5fbff; - --accent: #7fd8ff; - --accent-2: #a7e8ff; - --text: #e9f2ff; - --muted: #b6c8df; - --ok: #9cf0b4; - --warn: #ffd47e; - --shadow: rgba(0, 0, 0, 0.35); - --card: rgba(255, 255, 255, 0.06); - --card-border: rgba(255, 255, 255, 0.15); -} - * { + margin: 0; + padding: 0; box-sizing: border-box; } -html, +:root { + --bg: #fffdf7; + --text: #0f172a; + --muted: #475569; + --border: #0f172a; + --panel: #dff4ff; + --panel-2: #ffffff; + --accent: #00a6fb; + --accent-strong: #0077b6; + --highlight: #ffd23f; + --danger: #ff5d73; +} + body { - margin: 0; - min-height: 100%; + background: var(--bg); color: var(--text); - background: radial-gradient(circle at 15% 10%, #223a5f 0%, var(--bg-1) 28%, var(--bg-0) 100%); - font-family: "Trebuchet MS", "Segoe UI", Arial, sans-serif; - image-rendering: pixelated; + font-family: Arial, Helvetica, sans-serif; + font-size: 18px; + line-height: 1.6; + padding: 20px; } -body { - display: flex; - justify-content: center; - padding: 2rem 1rem 3rem; - position: relative; - overflow-x: hidden; +.container { + max-width: 920px; + margin: 0 auto; } -body::before, -body::after { - content: ""; - position: fixed; - inset: 0; - pointer-events: none; - z-index: 0; +header { + text-align: center; + margin-bottom: 30px; + border-bottom: 3px solid var(--border); + padding-bottom: 20px; } -body::before { - background-image: - radial-gradient(2px 2px at 15% 20%, rgba(255, 255, 255, 0.7) 50%, transparent 51%), - radial-gradient(2px 2px at 35% 40%, rgba(255, 255, 255, 0.5) 50%, transparent 51%), - radial-gradient(2px 2px at 65% 15%, rgba(255, 255, 255, 0.6) 50%, transparent 51%), - radial-gradient(2px 2px at 80% 75%, rgba(255, 255, 255, 0.7) 50%, transparent 51%), - radial-gradient(2px 2px at 25% 85%, rgba(255, 255, 255, 0.55) 50%, transparent 51%); - opacity: 0.35; -} - -body::after { - background: linear-gradient(180deg, transparent 0%, rgba(181, 227, 255, 0.08) 100%); -} - -.page { - width: min(1100px, 100%); - position: relative; - z-index: 1; - display: grid; - gap: 1rem; -} - -.hero, -.panel { - background: var(--card); - border: 1px solid var(--card-border); - border-radius: 14px; - box-shadow: 0 10px 35px var(--shadow); - backdrop-filter: blur(3px); -} - -.hero { - padding: 1rem 1.25rem; - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 0.75rem 1rem; -} - -.title-group h1 { - margin: 0; - font-size: clamp(1.5rem, 2.3vw, 2rem); +h1, +h2, +h3, +button, +summary, +.back-button { + font-family: "Arial Black", Arial, Helvetica, sans-serif; + font-weight: 900; letter-spacing: 0.02em; } -.title-group p { - margin: 0.3rem 0 0; - color: var(--muted); - max-width: 60ch; - font-size: 0.95rem; - line-height: 1.4; +h1 { + font-size: clamp(2.4rem, 5vw, 3.6rem); + margin-bottom: 10px; + line-height: 1; + text-transform: uppercase; } -.pill-row { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; -} - -.pill { - border: 1px solid rgba(167, 232, 255, 0.35); - background: rgba(127, 216, 255, 0.12); - color: var(--snow); - padding: 0.35rem 0.55rem; - border-radius: 999px; - font-size: 0.8rem; - white-space: nowrap; -} - -.layout { - display: grid; - grid-template-columns: 2fr 1fr; - gap: 1rem; -} - -.game-shell { - padding: 1rem; - display: grid; - gap: 0.8rem; -} - -.status-row { - display: grid; - gap: 0.5rem; -} - -.loading { +h2 { + font-size: 1.8rem; + margin: 0 0 16px; +} + +h3 { + font-size: 1.35rem; + margin: 0 0 14px; +} + +p, +ul { + margin-bottom: 16px; +} + +.subtitle { + color: var(--accent-strong); + font-size: 1rem; +} + +.back-button { + display: inline-block; + margin-bottom: 20px; + padding: 10px 15px; + background: var(--highlight); + color: var(--text); + text-decoration: none; + border: 3px solid var(--border); +} + +.back-button:hover, +.action-button:hover { + background: var(--text); + color: #fff; +} + +.info-box { + background: var(--highlight); + border: 3px solid var(--border); + padding: 16px; + margin: 20px 0; +} + +.canvas-container { + background: var(--panel); + border: 3px solid var(--border); + padding: 20px; + margin: 30px 0; +} + +.canvas-toolbar { display: flex; + justify-content: space-between; align-items: center; - gap: 0.55rem; + gap: 12px; + margin-bottom: 15px; + flex-wrap: wrap; +} + +.canvas-toolbar p { + margin: 0; color: var(--muted); - font-size: 0.9rem; - min-height: 1.1rem; + font-size: 0.95rem; + text-transform: uppercase; } -.spinner { - width: 14px; - height: 14px; - border: 2px solid rgba(255, 255, 255, 0.3); - border-top-color: var(--accent-2); - border-radius: 50%; - animation: spin 0.8s linear infinite; - flex: 0 0 auto; +.action-button { + padding: 10px 14px; + background: var(--danger); + color: white; + border: 3px solid var(--border); + cursor: pointer; + font: inherit; } -#status { - font-weight: 600; - color: var(--ice); -} - -#progress { - width: 100%; - height: 12px; - border: 0; - border-radius: 999px; - overflow: hidden; - background: rgba(255, 255, 255, 0.15); -} - -#progress::-webkit-progress-bar { - background: rgba(255, 255, 255, 0.15); -} - -#progress::-webkit-progress-value { - background: linear-gradient(90deg, #95dfff, #d8f4ff); -} - -#progress::-moz-progress-bar { - background: linear-gradient(90deg, #95dfff, #d8f4ff); -} - -.canvas-frame { - background: linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 12px; - padding: 0.6rem; +.canvas-shell { + display: flex; + justify-content: center; + align-items: center; + min-height: 520px; overflow: auto; + background: var(--panel-2); + border: 3px solid var(--border); + padding: 12px; } #canvas { display: block; - width: 100%; - max-width: 100%; margin: 0 auto; - border-radius: 8px; - border: 1px solid rgba(255, 255, 255, 0.18); + max-width: 100%; + height: auto; background: #000; outline: none; + image-rendering: pixelated; } -.controls { +.canvas-options { display: flex; flex-wrap: wrap; - align-items: center; - gap: 0.8rem; + gap: 14px; + margin-top: 15px; + font-size: 0.95rem; color: var(--muted); - font-size: 0.9rem; } -.controls label { +.canvas-options label { display: inline-flex; align-items: center; - gap: 0.35rem; - user-select: none; - cursor: pointer; + gap: 8px; } -.controls input[type="checkbox"] { - accent-color: #8ad6ff; - cursor: pointer; +.canvas-options input { + accent-color: var(--accent-strong); } -.btn { - border: 1px solid rgba(167, 232, 255, 0.6); - color: var(--snow); - background: linear-gradient(180deg, rgba(146, 217, 255, 0.35), rgba(88, 173, 222, 0.3)); - border-radius: 8px; - padding: 0.42rem 0.7rem; - font-weight: 600; - cursor: pointer; +.status { + margin-top: 15px; + min-height: 24px; + color: var(--accent-strong); + font-weight: 700; } -.btn:hover { - filter: brightness(1.08); +progress { + width: 100%; + height: 18px; + margin-top: 10px; + border: 3px solid var(--border); } -.sidebar { - padding: 1rem; - display: grid; - gap: 0.8rem; - align-content: start; -} - -.sidebar h2 { - margin: 0; - font-size: 1.05rem; -} - -.sidebar p, -.sidebar li { - margin: 0; - color: var(--muted); - font-size: 0.92rem; - line-height: 1.45; -} - -.sidebar ul { - margin: 0; - padding-left: 1.1rem; - display: grid; - gap: 0.45rem; -} - -.hint { - border-left: 3px solid var(--accent); - padding: 0.5rem 0.65rem; - background: rgba(127, 216, 255, 0.1); - border-radius: 6px; - color: #d5f2ff; -} - -#output { +progress[hidden] { display: none; +} + +details { + margin-top: 15px; +} + +summary { + cursor: pointer; +} + +.feature-list { + padding-left: 24px; +} + +.feature-list li { + margin-bottom: 10px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +textarea { width: 100%; min-height: 120px; - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 8px; - color: #fff; - background: #0a0f19; - font-family: Menlo, Monaco, Consolas, "Courier New", monospace; - font-size: 0.8rem; - padding: 0.5rem; + margin-top: 10px; + padding: 10px; + border: 3px solid var(--border); + font-family: "Courier New", monospace; + font-size: 0.9rem; resize: vertical; + background: white; + color: var(--text); } -@keyframes spin { - to { - transform: rotate(360deg); - } +footer { + text-align: center; + margin-top: 40px; + padding-top: 20px; + border-top: 3px solid var(--border); + font-size: 0.9rem; + color: var(--muted); } -@media (max-width: 920px) { - .layout { - grid-template-columns: 1fr; +@media (max-width: 768px) { + body { + padding: 16px; + font-size: 16px; + } + + h1 { + font-size: 2.2rem; + } + + .canvas-container { + padding: 15px; + } + + .canvas-shell { + min-height: 360px; } } diff --git a/projects/thin_ice/thin_ice.html b/projects/thin_ice/thin_ice.html index 50dbcbb..df72905 100644 --- a/projects/thin_ice/thin_ice.html +++ b/projects/thin_ice/thin_ice.html @@ -8,54 +8,63 @@ -
-
-
-

Thin Ice

+
+ ← Back + +
+

Thin Ice

+

Inspired by Club Penguin's Thin Ice mini-game.

+
+ +
+

+ You start on safe ice, breaking ice where you walk, trying to reach the finish tile before trapping yourself. +

+

- A game inspired by the Club Penguin mini-game of the same name. + See if you can get a gold medal on all 6 levels by breaking every single tile of ice!

-
-
-
-
- -
Downloading...
-
- -
+
+
+

Game

+ +
-
- -
+
+ +
-
- - - -
+
+ + +
- -
+
Downloading...
+ - +
+ Show console output + + +
+
+ +
+

How to Play

+
    +
  • Use WASM keys to move across the ice.
  • +
  • Plan ahead so you do not strand yourself on broken tiles.
  • +
  • Reach the end tile without falling into the water.
  • +
-
+ + + diff --git a/projects/tsp/canvas.js b/projects/tsp/canvas.js new file mode 100644 index 0000000..5b9be57 --- /dev/null +++ b/projects/tsp/canvas.js @@ -0,0 +1,19 @@ +export function setupCanvas(gl, canvas) { + canvas.width = canvas.clientWidth * window.devicePixelRatio; + canvas.height = canvas.clientHeight * window.devicePixelRatio; + gl.viewport(0, 0, canvas.width, canvas.height); + + return { + canvas, + gl, + aspectRatio: canvas.width / canvas.height + }; +} + +export function resizeCanvas(gl, canvas, program) { + canvas.width = canvas.clientWidth * window.devicePixelRatio; + canvas.height = canvas.clientHeight * window.devicePixelRatio; + gl.viewport(0, 0, canvas.width, canvas.height); + gl.useProgram(program); + gl.uniform2f(gl.getUniformLocation(program, "u_resolution"), canvas.width, canvas.height); +} diff --git a/projects/tsp/index.html b/projects/tsp/index.html new file mode 100644 index 0000000..79e77ae --- /dev/null +++ b/projects/tsp/index.html @@ -0,0 +1,74 @@ + + + + + + Travelling Salesman Problem + + + +
+ ← Back + +
+

Travelling Salesman Problem

+

An interactive route visualiser built with WebGL and WebAssembly.

+
+ +
+

+ After writing one of my first tutorials on the Travelling Salesman Problem, I wanted to revisit it with a slightly more technical stack. + The backend is written in Zig (compiled to WebAssembly), and the rendering is done directly in WebGL instead of p5.js or Raylib. +

+

+ This project was also inspired by sphaerophoria who started a series on creating his own map. +

+ +
+

+ Drag points around the canvas and watch the route update in real time. +

+
+
+ +
+
+
+ + + +
+
+ +
+ + HTML5 canvas not supported in browser + +
+ +
+
36 points
+

Tip: click and drag a point to recompute the route.

+
+
+ +
+

Technical Details

+

+ The backend is written in Zig, and the route is built in two passes: a nearest-neighbour pass followed by a 2-opt local search. +

+

+ Point coordinates are stored in WebAssembly memory, and the route order is recalculated whenever the layout changes. + Rendering is handled in WebGL to keep interaction smooth as the point count increases. +

+
+ +
+

Built with Zig, WebGL2 and WebAssembly

+
+
+ + + + + diff --git a/projects/tsp/main.js b/projects/tsp/main.js new file mode 100644 index 0000000..5eac282 --- /dev/null +++ b/projects/tsp/main.js @@ -0,0 +1,245 @@ +import { initWasm } from "./wasm.js"; +import { setupCanvas, resizeCanvas } from "./canvas.js"; +import { createShaderProgram } from "./shaders.js"; +import { draw } from "./tsp-gl.js"; +import { initPoints } from "./utils.js"; + +const DEFAULT_POINT_COUNT = 36; +const MIN_POINTS = 4; +const MAX_POINTS = 96; +const RADIUS_SIZE = 0.006; + +function clampPointCount(value) { + if (Number.isNaN(value)) { + return DEFAULT_POINT_COUNT; + } + + return Math.max(MIN_POINTS, Math.min(MAX_POINTS, value)); +} + +function createPointFragmentShader(pointCount) { + return ` + precision mediump float; + + uniform vec2 u_resolution; + uniform vec2 u_centers[${pointCount}]; + + void main() { + vec2 position = gl_FragCoord.xy / u_resolution; + float aspect = u_resolution.x / u_resolution.y; + vec2 scaledPosition = vec2(position.x * aspect, position.y); + float radius = ${RADIUS_SIZE}; + + for (int i = 0; i < ${pointCount}; i++) { + vec2 center = u_centers[i]; + vec2 scaledCenter = vec2(center.x * aspect, center.y); + float distance = length(scaledPosition - scaledCenter) * (1.0 / aspect); + + if (distance <= radius) { + gl_FragColor = vec4(0.5, center, 1.0); + return; + } + } + + gl_FragColor = vec4(0.1, 0.1, 0.1, 1.0); + } + `; +} + +function createLineFragmentShader() { + return ` + precision mediump float; + + void main(void) { + gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0); + } + `; +} + +async function run() { + const canvas = document.getElementById("tsp-canvas"); + const pointCountInput = document.getElementById("pointCountInput"); + const applyPointCountButton = document.getElementById("applyPointCountButton"); + const pointCountStatus = document.getElementById("pointCountStatus"); + + if (!canvas || !pointCountInput || !applyPointCountButton || !pointCountStatus) { + return; + } + + const gl = canvas.getContext("webgl2"); + if (!gl) { + pointCountStatus.textContent = "WebGL2 unavailable in this browser."; + return; + } + + gl.clearColor(0.1, 0.1, 0.1, 1); + + const { + initialisePoints, + getPointOrder, + memory + } = await initWasm(); + + const vertexCode = ` + attribute vec2 a_position; + void main(void) { + gl_Position = vec4(a_position, 0.0, 1.0); + } +`; + + setupCanvas(gl, canvas); + + const positionBuffer = gl.createBuffer(); + const lineBuffer = gl.createBuffer(); + + if (!positionBuffer || !lineBuffer) { + pointCountStatus.textContent = "Unable to initialise WebGL buffers."; + return; + } + + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + const positions = [-1, -1, 3, -1, -1, 3]; + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW); + + const state = { + scene: null, + draggingIndex: -1 + }; + + function updateStatus(pointCount) { + pointCountStatus.textContent = `${pointCount} point${pointCount === 1 ? "" : "s"}`; + } + + function recalculateRoute(scene) { + const pointOrderPtr = getPointOrder(scene.pointsPtr, scene.pointCount); + scene.pointOrder = Array.from(new Uint32Array(memory.buffer, pointOrderPtr, scene.pointCount)); + } + + function syncPointsToMemory(scene) { + scene.pointArray = new Float32Array(memory.buffer, scene.pointsPtr, scene.pointCount * 2); + + for (let i = 0; i < scene.points.length; i++) { + scene.pointArray[i * 2] = scene.points[i].cx; + scene.pointArray[i * 2 + 1] = scene.points[i].cy; + } + } + + function initialiseScene(rawPointCount) { + const pointCount = clampPointCount(rawPointCount); + + if (state.scene) { + gl.deleteProgram(state.scene.program); + gl.deleteProgram(state.scene.lineProgram); + } + + const pointsPtr = initialisePoints(pointCount, Date.now()); + const pointArray = new Float32Array(memory.buffer, pointsPtr, pointCount * 2); + const points = initPoints(pointArray); + const program = createShaderProgram(gl, vertexCode, createPointFragmentShader(pointCount)); + const lineProgram = createShaderProgram(gl, vertexCode, createLineFragmentShader()); + + state.scene = { + pointCount, + pointsPtr, + pointArray, + points, + pointOrder: [], + program, + lineProgram + }; + + recalculateRoute(state.scene); + resizeCanvas(gl, canvas, program); + updateStatus(pointCount); + pointCountInput.value = String(pointCount); + } + + canvas.addEventListener("mousedown", (e) => { + if (!state.scene) { + return; + } + + const rect = canvas.getBoundingClientRect(); + const x = ((e.clientX - rect.left) / rect.width) * 2 - 1; + const y = 1 - ((e.clientY - rect.top) / rect.height) * 2; + + for (let i = 0; i < state.scene.points.length; i++) { + const point = state.scene.points[i]; + const dx = (0.5 * x + 0.5) - point.cx; + const dy = (0.5 * y + 0.5) - point.cy; + const distance = Math.hypot(dx, dy); + + if (distance <= RADIUS_SIZE) { + state.draggingIndex = i; + break; + } + } + }); + + canvas.addEventListener("mousemove", (e) => { + if (!state.scene || state.draggingIndex === -1) { + return; + } + + const rect = canvas.getBoundingClientRect(); + state.scene.points[state.draggingIndex].cx = (e.clientX - rect.left) / rect.width; + state.scene.points[state.draggingIndex].cy = 1 - (e.clientY - rect.top) / rect.height; + + syncPointsToMemory(state.scene); + recalculateRoute(state.scene); + }); + + function stopDragging() { + state.draggingIndex = -1; + } + + canvas.addEventListener("mouseup", stopDragging); + canvas.addEventListener("mouseleave", stopDragging); + + function applyPointCount() { + const nextPointCount = clampPointCount(Number.parseInt(pointCountInput.value, 10)); + initialiseScene(nextPointCount); + } + + applyPointCountButton.addEventListener("click", applyPointCount); + pointCountInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + applyPointCount(); + } + }); + + pointCountInput.addEventListener("blur", () => { + pointCountInput.value = String(clampPointCount(Number.parseInt(pointCountInput.value, 10))); + }); + + window.addEventListener("resize", () => { + if (!state.scene) { + return; + } + + resizeCanvas(gl, canvas, state.scene.program); + }); + + initialiseScene(DEFAULT_POINT_COUNT); + + function render() { + if (state.scene) { + draw( + gl, + state.scene.points, + state.scene.program, + state.scene.lineProgram, + positionBuffer, + lineBuffer, + state.scene.pointOrder, + 1 + ); + } + + requestAnimationFrame(render); + } + + render(); +} + +await run(); diff --git a/projects/tsp/main.wasm b/projects/tsp/main.wasm new file mode 100755 index 0000000000000000000000000000000000000000..25a2a22efd2e06ee5d5ebc76696e04cddbad841d GIT binary patch literal 3491 zcmc&%-)~e!6rNxA-rb$O?cG|SP$K7E(G-#@d{JW17mbPkfH6M!BgS`QjQE||dv{yF#5cFuy?18L%$#$+ z?|d^ACr+P~LI`=VI%(SY$o8bhLrezT!c+M^*_KO__&Mv_XZf`~8F~}E>4AneGB8@o zFqB$Em5MTfd=;%gOVJ3cuy@PLw}p+4n(*Y*$+?9yN+dIw*Db-HzIYG(T77v~m)ty(FZw1KMk^(VEP+Z$gg4R@Ag z`;ldQgxfcpsonmS6v_25JwnQjEz?=?WftObeL! zh1Q$2LTdfOm?n$}cNZUdZ%u3t#CCB2r0Sin-4N;R&rnYV#slqcsgO+2my zmNfZ&U6nkQ@U5MvqAsw4a{KZkRiQm9kU!wW$AD{gB-mULH{HVQ{Nsd}JAHGEL zV)N6lul@Yp?^kbp-d@UNbJ+L&_VJw$Zr)n`<+|_N#eH^8DbNQSygr|5CsN}*HKdPo zJOtlFa)Esv6M~F`V}?CaC&BC{&fWrNa=x6=r+gha7m|A*% z;WPWZ3<`h}k5VQtW}qHBt}ik}*-EleJf29ZTx@Q; zylC$PK@C;Tl^&PrqYX90N%Kr4nXE8mq()So3kjhB1?2{N25*)ILn-QML=C1P&__-I zV?fa%pdV6{JLF1Glk3}#T#AG&K!=xLLTVH z!{li~+AZa213L`h^=zI!ln$b?g&m^-YB1tkoSaW1Z&Hj>G!Yw=Dh)W5r(3C?Us_p& zxF|su(rrKik0Cq;kc*6m^K?7bsC7dg?1Vf7Vc@7UhIxD0(8*;EE3Ko`&{nGD8MFje zQE9OQBN1XK+r!0-KB}e>ZA0h-sig|-==6g@g~ck>sc#j@k{&!Tlxr}?I!GN7h8Kn@ zqKbdGML>ycB3m)Y4D2LX#@8~wPNrKp5raH1SXY*+Y+0omo78*>11~U*X@F3;KdW&l zeto}2hlC*o$y*(kLGX$iH})r~SOAJM0Q}X(kVN@|rL6m`(Ha`Pli8%k(SvoCfwpM! zO9Bm&kRU$-oO*)oK{TaKn+NK2SE5l^NE&pS)P6OHhj~gdfy5JX2#+LB6{iCISm#Mu zM;}swln7Grbqq-r;2ylIFlwA#-Hp_K`7d#n5wQCXobQBz$T;YJ9~m?bN{BF&6AnrK zKf`!b%CjW)A~uPiYtS)uo!dwremd<{``CZ6x@z|#T2frrIDWBW5g-}{O4WD+*a`+1 zMEpXTY9V$#s&ual$oTzISMhg-2WuSEk2Nkp#fHTR7qZ3$6c!o>Vast|V`~aZjf28 z2dY7Mi^9^VI}}#c7ghupxD@NGbam=^YKtTUWbQ9DZLcV4%7X&utl8~zi=tI-*Zcv| z4?hEtu+2k&HExE$jqmzppx&3D20)ERF zH_DDxxgy-m86jQ^84PiXb~`K%h4Qebz)nilZc0SJ7pv*nO!QpFc6>8LOseIU&w0N= z7?I)1hPB~h(jr^~x~_|UpiOcd7JkUJ4-MNNBz7p+ihPGfzC*C6lkc`arLqWOY>;V# zOyj-@fgycln6F;^2Xsu1^h1`oJJTHi++6PJN8pX|Z5>O%r;$G&k}h-TLn{OGL*{`) zhy3N%8>rb$_PX1I4KmtGp|(2_Gb<8vh=mcndz&%Ci8^e*-5h>UgdddQb8yIhC%iKB z4Kd!&E8J$mm>Vl=oOw^Hl2p2&5H5UN96%nXSbTFHGF?Unko+3@UX(X4#I;{C#@ZDB V!1#ZRhLjLO<7@B@@a=pO@ec&rzqtSa literal 0 HcmV?d00001 diff --git a/projects/tsp/point.js b/projects/tsp/point.js new file mode 100644 index 0000000..7e6147e --- /dev/null +++ b/projects/tsp/point.js @@ -0,0 +1,6 @@ +class Point { + constructor(x, y) { + this.cx = x; + this.cy = y; + } +} diff --git a/projects/tsp/shaders.js b/projects/tsp/shaders.js new file mode 100644 index 0000000..704e2d3 --- /dev/null +++ b/projects/tsp/shaders.js @@ -0,0 +1,20 @@ +// shaders.js +export function loadShader(gl, type, source) { + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + return shader; +} + +export function createShaderProgram(gl, vertexCode, fragCode) { + const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vertexCode); + const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fragCode); + + const program = gl.createProgram(); + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + + return program; +} + diff --git a/projects/tsp/style.css b/projects/tsp/style.css new file mode 100644 index 0000000..5582b95 --- /dev/null +++ b/projects/tsp/style.css @@ -0,0 +1,202 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: white; + color: black; + font-family: Arial, sans-serif; + font-size: 16px; + line-height: 1.6; + padding: 20px; +} + +.container { + max-width: 900px; + margin: 0 auto; +} + +header { + text-align: center; + margin-bottom: 30px; + border-bottom: 1px solid #ccc; + padding-bottom: 20px; +} + +h1 { + font-size: 2.5rem; + margin-bottom: 10px; +} + +h2 { + font-size: 1.8rem; + margin: 20px 0 15px; +} + +h3 { + font-size: 1.3rem; + margin: 15px 0; +} + +p { + margin-bottom: 15px; + color: #333; +} + +.subtitle { + color: #666; +} + +.back-button { + display: inline-block; + margin-bottom: 20px; + padding: 10px 15px; + background: #f0f0f0; + color: black; + text-decoration: none; + border: 1px solid #ccc; + border-radius: 4px; +} + +.back-button:hover, +.action-button:hover { + background: #e0e0e0; +} + +.info-box { + background: #f9f9f9; + border-left: 4px solid #333; + padding: 15px; + margin: 20px 0; +} + +.canvas-container { + background: #f9f9f9; + border: 1px solid #ddd; + border-radius: 4px; + padding: 20px; + margin: 30px 0; +} + +.canvas-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 15px; + flex-wrap: wrap; +} + +.canvas-toolbar p { + margin: 0; + color: #666; + font-size: 0.95rem; +} + +.control-group { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.control-group label { + color: #555; + font-size: 0.95rem; +} + +.control-group input { + width: 90px; + padding: 8px 10px; + border: 1px solid #ccc; + border-radius: 4px; + font: inherit; + background: white; + color: black; +} + +.action-button { + padding: 8px 12px; + background: #f0f0f0; + color: black; + border: 1px solid #ccc; + border-radius: 4px; + cursor: pointer; + font: inherit; +} + +.canvas-shell { + display: flex; + justify-content: center; + align-items: center; + min-height: 500px; + overflow: auto; + background: white; + border: 1px solid #ddd; +} + +#tsp-canvas { + display: block; + margin: 0 auto; + width: 100%; + height: 560px; + max-width: 100%; + background: black; + outline: none; + image-rendering: crisp-edges; +} + +.status-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-top: 15px; +} + +.status { + min-height: 24px; + color: #666; + margin: 0; +} + +.canvas-note { + margin: 0; + color: #666; + font-size: 0.95rem; +} + +footer { + text-align: center; + margin-top: 40px; + padding-top: 20px; + border-top: 1px solid #ccc; + font-size: 0.85rem; + color: #666; +} + +@media (max-width: 768px) { + body { + padding: 16px; + } + + h1 { + font-size: 2rem; + } + + .canvas-container { + padding: 15px; + } + + .canvas-shell { + min-height: 360px; + } + + #tsp-canvas { + height: 400px; + } +} + diff --git a/projects/tsp/tsp-gl.js b/projects/tsp/tsp-gl.js new file mode 100644 index 0000000..02ae3b5 --- /dev/null +++ b/projects/tsp/tsp-gl.js @@ -0,0 +1,46 @@ +// tsp-gl.js +import { updateLines } from "./utils.js"; + +export function draw(gl, points, program, lineProgram, positionBuffer, lineBuffer, pointOrder, aspectRatio) { + gl.clear(gl.COLOR_BUFFER_BIT); + + // Draw Circles + updateCircleUniforms(gl, points, program, positionBuffer); + + // Draw Lines + const lineVertices = updateLines(points, pointOrder); + drawLines(gl, lineProgram, lineBuffer, lineVertices); +} + +function updateCircleUniforms(gl, points, program, positionBuffer) { + const centerArray = new Float32Array(points.length * 2); + + points.forEach((point, i) => { + centerArray[i * 2] = point.cx; + centerArray[i * 2 + 1] = point.cy; + }); + + gl.useProgram(program); + + gl.uniform2fv(gl.getUniformLocation(program, "u_centers"), centerArray); + + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + const positionAttribLoc = gl.getAttribLocation(program, "a_position"); + gl.enableVertexAttribArray(positionAttribLoc); + gl.vertexAttribPointer(positionAttribLoc, 2, gl.FLOAT, false, 0, 0); + + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 3); +} + +function drawLines(gl, program, buffer, lineVertices) { + gl.useProgram(program); + + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData(gl.ARRAY_BUFFER, lineVertices, gl.DYNAMIC_DRAW); + + const positionAttribLoc = gl.getAttribLocation(program, "a_position"); + gl.enableVertexAttribArray(positionAttribLoc); + gl.vertexAttribPointer(positionAttribLoc, 2, gl.FLOAT, false, 0, 0); + + gl.drawArrays(gl.LINES, 0, lineVertices.length / 2); +} diff --git a/projects/tsp/utils.js b/projects/tsp/utils.js new file mode 100644 index 0000000..192f952 --- /dev/null +++ b/projects/tsp/utils.js @@ -0,0 +1,19 @@ +// utils.js +export function initPoints(points) { + let cs = []; + for (let i = 0; i < points.length; i += 2) { + cs.push(new Point(points[i], points[i + 1])); + } + return cs; +} + +export function updateLines(points, pointOrder) { + const lineVertices = []; + for (let i = 0; i < pointOrder.length - 1; i++) { + const j = pointOrder[i]; + const k = pointOrder[i + 1]; + lineVertices.push(points[j].cx * 2 - 1, points[j].cy * 2 - 1); + lineVertices.push(points[k].cx * 2 - 1, points[k].cy * 2 - 1); + } + return new Float32Array(lineVertices); +} diff --git a/projects/tsp/wasm.js b/projects/tsp/wasm.js new file mode 100644 index 0000000..5d969e6 --- /dev/null +++ b/projects/tsp/wasm.js @@ -0,0 +1,24 @@ +export async function initWasm() { + + let wasmMemory = new WebAssembly.Memory({ + initial: 256, + maximum: 256 + }); + + let importObject = { + env: { + memory: wasmMemory, + } + }; + + const response = await fetch("./main.wasm"); + const wasmModule = await WebAssembly.instantiateStreaming(response, importObject); + const { initialisePoints, getPointOrder, memory } = wasmModule.instance.exports; + + return { + initialisePoints, + getPointOrder, + memory, + wasmMemory + } +}