add interpolation to cursor reticles

cursor reticles are now interpolated to the update interval, so they should be smooth at > 60 fps
This commit is contained in:
りき萌 2025-06-26 18:48:16 +02:00
parent bebc2daa95
commit a40480a464
3 changed files with 81 additions and 19 deletions

View file

@ -20,18 +20,34 @@ export function listen(...listenerSpecs) {
} }
export function debounce(time, fn) { 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 timeout = null;
let queued = null; let queued = null;
const callFn = (args) => {
fn(...args);
timeout = setTimeout(() => {
timeout = null;
if (queued != null) {
callFn(queued);
queued = null;
}
}, time);
};
return (...args) => { return (...args) => {
if (timeout == null) { if (timeout == null) {
fn(...args); callFn(args);
timeout = setTimeout(() => {
timeout = null;
if (queued != null) {
fn(...queued);
queued = null;
}
}, time);
} else { } else {
queued = args; queued = args;
} }

View file

@ -26,6 +26,7 @@ let connectionStatus = main.querySelector("rkgk-connection-status");
document.getElementById("js-loading").remove(); document.getElementById("js-loading").remove();
reticleRenderer.connectViewport(canvasRenderer.viewport); reticleRenderer.connectViewport(canvasRenderer.viewport);
reticleRenderer.updateInterval = updateInterval;
function updateUrl(session, viewport) { function updateUrl(session, viewport) {
let url = new URL(window.location); 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) => { canvasRenderer.addEventListener(".cursor", async (event) => {
reportCursor(event.x, event.y); reportCursor(event.x, event.y);
}); });

View file

@ -7,6 +7,12 @@ export class Reticle extends HTMLElement {
export class ReticleCursor extends Reticle { export class ReticleCursor extends Reticle {
#container; #container;
#lastX = 0;
#lastY = 0;
#targetX = 0;
#targetY = 0;
#lastUpdate = 0;
constructor(nickname) { constructor(nickname) {
super(); super();
this.nickname = nickname; this.nickname = nickname;
@ -38,23 +44,39 @@ export class ReticleCursor extends Reticle {
} }
setCursor(x, y) { setCursor(x, y) {
this.x = x; this.#lastX = this.#targetX;
this.y = y; this.#lastY = this.#targetY;
this.#targetX = x;
this.#targetY = y;
this.#lastUpdate = performance.now();
this.dispatchEvent(new Event(".update")); this.dispatchEvent(new Event(".update"));
} }
render(viewport, windowSize) { render(viewport, updateInterval, windowSize) {
let [viewportX, viewportY] = viewport.toScreenSpace(this.x, this.y, 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)`; 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); customElements.define("rkgk-reticle-cursor", ReticleCursor);
export class ReticleRenderer extends HTMLElement { export class ReticleRenderer extends HTMLElement {
updateInterval = 1000 / 10; // a really wrong value, you should definitely override this
#reticles = new Set(); #reticles = new Set();
#reticlesDiv; #reticlesDiv;
#animatingReticles = new Set();
connectedCallback() { connectedCallback() {
this.#reticlesDiv = this.appendChild(document.createElement("div")); this.#reticlesDiv = this.appendChild(document.createElement("div"));
this.#reticlesDiv.classList.add("reticles"); this.#reticlesDiv.classList.add("reticles");
@ -72,13 +94,13 @@ export class ReticleRenderer extends HTMLElement {
addReticle(reticle) { addReticle(reticle) {
if (!this.#reticles.has(reticle)) { if (!this.#reticles.has(reticle)) {
reticle.addEventListener(".update", () => { reticle.addEventListener(".update", () => {
if (this.viewport != null) { let needsKickstart = this.#animatingReticles.size == 0;
reticle.render(this.viewport, { this.#animatingReticles.add(reticle);
width: this.clientWidth, if (needsKickstart) {
height: this.clientHeight, this.#tickAnimatingReticles();
});
} }
}); });
this.#reticles.add(reticle); this.#reticles.add(reticle);
this.#reticlesDiv.appendChild(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() { render() {
if (this.viewport == null) { if (this.viewport == null) {
console.debug("viewport is disconnected, skipping transform update"); 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 }; let windowSize = { width: this.clientWidth, height: this.clientHeight };
for (let reticle of this.#reticles.values()) { 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); customElements.define("rkgk-reticle-renderer", ReticleRenderer);
function lerp(a, b, t) {
return a + (b - a) * t;
}