From a40480a4642b64c0c3c995cb42dde8f3ff6ea539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=83=AA=E3=82=AD=E8=90=8C?= Date: Thu, 26 Jun 2025 18:48:16 +0200 Subject: [PATCH] add interpolation to cursor reticles cursor reticles are now interpolated to the update interval, so they should be smooth at > 60 fps --- static/framework.js | 32 ++++++++++++++----- static/index.js | 3 +- static/reticle-renderer.js | 65 ++++++++++++++++++++++++++++++++------ 3 files changed, 81 insertions(+), 19 deletions(-) diff --git a/static/framework.js b/static/framework.js index c627b6c..cf18f65 100644 --- a/static/framework.js +++ b/static/framework.js @@ -20,18 +20,34 @@ export function listen(...listenerSpecs) { } export function debounce(time, fn) { + // This function is kind of tricky, but basically: + // + // - we want to guarantee `fn` is called at most once every `time` milliseconds + // - at the same time, in case debounced `fn` is called during an ongoing timeout, we want to + // queue up another run, and run it immediately after `time` passes + // - at the same time, in case this catch-up condition occurs, we must also ensure there's a + // delay after `fn` is called + // + // yielding the recursive solution below. + let timeout = null; let queued = null; + + const callFn = (args) => { + fn(...args); + + timeout = setTimeout(() => { + timeout = null; + if (queued != null) { + callFn(queued); + queued = null; + } + }, time); + }; + return (...args) => { if (timeout == null) { - fn(...args); - timeout = setTimeout(() => { - timeout = null; - if (queued != null) { - fn(...queued); - queued = null; - } - }, time); + callFn(args); } else { queued = args; } diff --git a/static/index.js b/static/index.js index 9a82474..4fb9f78 100644 --- a/static/index.js +++ b/static/index.js @@ -26,6 +26,7 @@ let connectionStatus = main.querySelector("rkgk-connection-status"); document.getElementById("js-loading").remove(); reticleRenderer.connectViewport(canvasRenderer.viewport); +reticleRenderer.updateInterval = updateInterval; function updateUrl(session, viewport) { let url = new URL(window.location); @@ -231,7 +232,7 @@ function readUrl(urlString) { } }); - let reportCursor = debounce(updateInterval, (x, y) => session.sendCursor(x, y)); + let reportCursor = debounce(updateInterval, (x, y) => session.sendCursor(x, y), console.log); canvasRenderer.addEventListener(".cursor", async (event) => { reportCursor(event.x, event.y); }); diff --git a/static/reticle-renderer.js b/static/reticle-renderer.js index 595a52c..5f2614f 100644 --- a/static/reticle-renderer.js +++ b/static/reticle-renderer.js @@ -7,6 +7,12 @@ export class Reticle extends HTMLElement { export class ReticleCursor extends Reticle { #container; + #lastX = 0; + #lastY = 0; + #targetX = 0; + #targetY = 0; + #lastUpdate = 0; + constructor(nickname) { super(); this.nickname = nickname; @@ -38,23 +44,39 @@ export class ReticleCursor extends Reticle { } setCursor(x, y) { - this.x = x; - this.y = y; + this.#lastX = this.#targetX; + this.#lastY = this.#targetY; + this.#targetX = x; + this.#targetY = y; + this.#lastUpdate = performance.now(); this.dispatchEvent(new Event(".update")); } - render(viewport, windowSize) { - let [viewportX, viewportY] = viewport.toScreenSpace(this.x, this.y, windowSize); + render(viewport, updateInterval, windowSize) { + let sinceLastUpdate = performance.now() - this.#lastUpdate; + let t = Math.max(0, Math.min(1, sinceLastUpdate / updateInterval)); + + let x = lerp(this.#lastX, this.#targetX, t); + let y = lerp(this.#lastY, this.#targetY, t); + + let [viewportX, viewportY] = viewport.toScreenSpace(x, y, windowSize); this.style.transform = `translate(${viewportX}px, ${viewportY}px)`; + + let needsRerender = t < 1; // if the linear animation isn't yet done + return needsRerender; } } customElements.define("rkgk-reticle-cursor", ReticleCursor); export class ReticleRenderer extends HTMLElement { + updateInterval = 1000 / 10; // a really wrong value, you should definitely override this + #reticles = new Set(); #reticlesDiv; + #animatingReticles = new Set(); + connectedCallback() { this.#reticlesDiv = this.appendChild(document.createElement("div")); this.#reticlesDiv.classList.add("reticles"); @@ -72,13 +94,13 @@ export class ReticleRenderer extends HTMLElement { addReticle(reticle) { if (!this.#reticles.has(reticle)) { reticle.addEventListener(".update", () => { - if (this.viewport != null) { - reticle.render(this.viewport, { - width: this.clientWidth, - height: this.clientHeight, - }); + let needsKickstart = this.#animatingReticles.size == 0; + this.#animatingReticles.add(reticle); + if (needsKickstart) { + this.#tickAnimatingReticles(); } }); + this.#reticles.add(reticle); this.#reticlesDiv.appendChild(reticle); } @@ -91,6 +113,25 @@ export class ReticleRenderer extends HTMLElement { } } + #tickAnimatingReticles() { + if (this.viewport == null) return; + + let windowSize = { + width: this.clientWidth, + height: this.clientHeight, + }; + for (let reticle of this.#animatingReticles) { + let needsUpdate = reticle.render(this.viewport, this.updateInterval, windowSize); + if (!needsUpdate) { + this.#animatingReticles.delete(reticle); + } + } + + if (this.#animatingReticles.size > 0) { + requestAnimationFrame(() => this.#tickAnimatingReticles()); + } + } + render() { if (this.viewport == null) { console.debug("viewport is disconnected, skipping transform update"); @@ -99,9 +140,13 @@ export class ReticleRenderer extends HTMLElement { let windowSize = { width: this.clientWidth, height: this.clientHeight }; for (let reticle of this.#reticles.values()) { - reticle.render(this.viewport, windowSize); + reticle.render(this.viewport, this.updateInterval, windowSize); } } } customElements.define("rkgk-reticle-renderer", ReticleRenderer); + +function lerp(a, b, t) { + return a + (b - a) * t; +}