From 72fdcf260140df4ddfbddade0a65a23b97a3de08 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 16 Mar 2021 20:32:47 -0600 Subject: [PATCH] Better shaders. Scrolling pool. Better transitions --- src/components/TxRender.svelte | 33 +++--- src/components/TxViz.svelte | 29 ++++- src/controllers/TxController.js | 47 ++++---- src/controllers/TxStream.js | 10 +- src/models/BitcoinTx.js | 13 ++- src/models/TxBlockScene.js | 80 ++++++++++---- src/models/TxPoolScene.js | 183 +++++++++++++++++++------------- src/models/TxSprite.js | 169 ++++++++++++++++------------- src/models/TxView.js | 33 +++--- src/shaders/tx.vert | 34 +++--- src/stores.js | 1 + src/utils/memory.js | 112 +++++++++++++++++++ 12 files changed, 503 insertions(+), 241 deletions(-) create mode 100644 src/utils/memory.js diff --git a/src/components/TxRender.svelte b/src/components/TxRender.svelte index cc3c6d0..6afed29 100644 --- a/src/components/TxRender.svelte +++ b/src/components/TxRender.svelte @@ -21,15 +21,15 @@ export let running = false // Shader attributes + // each attribute contains [x: startValue, y: endValue, z: startTime, w: rate] + // shader interpolates between start and end values at the given rate, from the given time const attribs = { - startTime: { type: 'FLOAT', count: 1, pointer: null }, - zIndex: { type: 'FLOAT', count: 1, pointer: null }, - speed: { type: 'FLOAT', count: 1, pointer: null }, - positions: { type: 'FLOAT', count: 4, pointer: null }, - sizes: { type: 'FLOAT', count: 2, pointer: null }, - palettes: { type: 'FLOAT', count: 2, pointer: null }, - colors: { type: 'FLOAT', count: 2, pointer: null }, - alphas: { type: 'FLOAT', count: 2, pointer: null } + posX: { type: 'FLOAT', count: 4, pointer: null }, + posY: { type: 'FLOAT', count: 4, pointer: null }, + sizes: { type: 'FLOAT', count: 4, pointer: null }, + palettes: { type: 'FLOAT', count: 4, pointer: null }, + colors: { type: 'FLOAT', count: 4, pointer: null }, + alphas: { type: 'FLOAT', count: 4, pointer: null } } // Auto-calculate the number of bytes per vertex based on specified attributes const stride = Object.values(attribs).reduce((total, attrib) => { @@ -58,9 +58,10 @@ function getTxPointArray () { if (controller) { - return new Float32Array( - controller.getScenes().flatMap(scene => scene.getVertexData()) - ) + return controller.getVertexData() + // return new Float32Array( + // controller.getScenes().flatMap(scene => scene.getVertexData()) + // ) } else return [] } @@ -136,7 +137,9 @@ }) /* DRAW */ - gl.drawArrays(gl.POINTS, 0, pointArray.length / 3) + if (pointArray.length) { + gl.drawArrays(gl.POINTS, 0, pointArray.length / 3) + } /* LOOP */ window.requestAnimationFrame(currentTime => { @@ -214,6 +217,8 @@ colorTexture = loadColorTexture(gl, '#f7941d', 'rgb(0%,100%,80%)', 500); running = true + + console.log(this) }) @@ -222,9 +227,9 @@ position: absolute; left: 0; right: 0; - top: -5px; + top: 0; bottom: 0; - pointer-events: none; + /* pointer-events: none; */ overflow: hidden; } diff --git a/src/components/TxViz.svelte b/src/components/TxViz.svelte index 03a60cf..d846ba4 100644 --- a/src/components/TxViz.svelte +++ b/src/components/TxViz.svelte @@ -3,13 +3,12 @@ import TxController from '../controllers/TxController.js' import TxRender from './TxRender.svelte' import getTxStream from '../controllers/TxStream.js' - import { darkMode, serverConnected, serverDelay, txQueueLength } from '../stores.js' + import { darkMode, serverConnected, serverDelay, txQueueLength, txCount } from '../stores.js' import BitcoinBlock from '../models/BitcoinBlock.js' let width = window.innerWidth let height = window.innerHeight let txController - let txCount = 0 let blockCount = 0 let running = false let txStream = getTxStream() @@ -53,6 +52,10 @@ // })) } + function fakeTx () { + txController.simulateDumpTx(1) + } + function fakeTxs () { txController.simulateDumpTx(200) } @@ -107,6 +110,10 @@ position: absolute; top: 20px; right: 20px; + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: flex-end; .status-light { display: block; @@ -124,6 +131,20 @@ background: greenyellow; } } + + .stat-counter { + margin-top: 5px; + + &.red { + color: red; + } + &.amber { + color: yellow; + } + &.green { + color: greenyellow; + } + } } @@ -134,12 +155,14 @@
+
- { $txQueueLength }
+ { $txQueueLength } + { $txCount }
diff --git a/src/controllers/TxController.js b/src/controllers/TxController.js index 544e8bf..d8fee46 100644 --- a/src/controllers/TxController.js +++ b/src/controllers/TxController.js @@ -2,13 +2,15 @@ import TxPoolScene from '../models/TxPoolScene.js' import TxBlockScene from '../models/TxBlockScene.js' import BitcoinTx from '../models/BitcoinTx.js' import BitcoinBlock from '../models/BitcoinBlock.js' +import { FastVertexArray } from '../utils/memory.js' import { txQueueLength } from '../stores.js' export default class TxController { constructor ({ width, height }) { + this.vertexArray = new FastVertexArray(2048, 24) this.txs = {} this.expiredTxs = {} - this.pool = new TxPoolScene({ width, height, layer: 0.0 }) + this.pool = new TxPoolScene({ width, height, layer: 0.0, controller: this }) this.blocks = {} this.clearBlockTimeout = null @@ -19,6 +21,10 @@ export default class TxController { this.scheduleQueue(1000) } + getVertexData () { + return this.vertexArray.getVertexData() + } + getScenes () { return [this.pool, ...Object.values(this.blocks)] } @@ -30,7 +36,8 @@ export default class TxController { }) } - addTx (tx) { + addTx (txData) { + const tx = new BitcoinTx(txData, this.vertexArray) if (!this.txs[tx.id] && !this.expiredTxs[tx.id]) { this.pendingTxs.push([tx, Date.now()]) txQueueLength.increment() @@ -77,7 +84,8 @@ export default class TxController { }, delay) } - addBlock (block) { + addBlock (blockData) { + const block = new BitcoinBlock(blockData) if (this.clearBlockTimeout) clearTimeout(this.clearBlockTimeout) this.expiredTxs = {} @@ -86,7 +94,7 @@ export default class TxController { if (!this.blocks[blockId].expired) this.clearBlock(blockId) }) - this.blocks[block.id] = new TxBlockScene({ width: 500, height: 500, layer: 1.0 }) + this.blocks[block.id] = new TxBlockScene({ width: 500, height: 500, layer: 1.0, blockId: block.id, controller: this }) let knownCount = 0 let unknownCount = 0 for (let i = 0; i < block.txns.length; i++) { @@ -99,14 +107,14 @@ export default class TxController { const tx = new BitcoinTx({ ...block.txns[i], block: block.id - }) + }, this.vertexArray) this.txs[tx.id] = tx this.blocks[block.id].insert(this.txs[tx.id], false) } this.expiredTxs[block.txns[i].id] = true } console.log(`New block with ${knownCount} known transactions and ${unknownCount} unknown transactions`) - this.blocks[block.id].layoutAll() + this.blocks[block.id].initialLayout() setTimeout(() => { this.pool.layoutAll() }, 2000) this.clearBlockTimeout = setTimeout(() => { this.clearBlock(block.id) }, 10000) @@ -129,7 +137,7 @@ export default class TxController { // } // }) const simulatedTxns = [] - Object.values(this.txs).forEach(tx => { + Object.values(this.pool.txs).forEach(tx => { if (Math.random() < 0.5) { simulatedTxns.push({ version: tx.version, @@ -150,7 +158,7 @@ export default class TxController { txn_count: 20, txns: simulatedTxns })) - }, 2500) + }, 0) } simulateDumpTx (n) { @@ -160,22 +168,25 @@ export default class TxController { time: Date.now(), id: `simulated_${i}_${Math.random()}`, value: Math.floor(Math.random() * 100000) - })) + }, this.vertexArray)) } } clearBlock (id) { if (this.blocks[id]) { this.blocks[id].expire() - setTimeout(() => { - const txs = this.blocks[id].getTxList() - for (let i = 0; i < txs.length; i++) { - if (this.blocks[id].remove(txs[i])) { - delete this.txs[txs[i]] - } - } - delete this.blocks[id] - }, 3000) } } + + destroyTx (id) { + this.getScenes().forEach(scene => { + scene.remove(id) + }) + if (this.txs[id]) this.txs[id].destroy() + delete this.txs[id] + } + + destroyBlock (id) { + if (this.blocks) delete this.blocks[id] + } } diff --git a/src/controllers/TxStream.js b/src/controllers/TxStream.js index d4243fd..71ee3b6 100644 --- a/src/controllers/TxStream.js +++ b/src/controllers/TxStream.js @@ -1,5 +1,3 @@ -import BitcoinTx from '../models/BitcoinTx.js' -import BitcoinBlock from '../models/BitcoinBlock.js' import { serverConnected, serverDelay } from '../stores.js' class TxStream { @@ -88,12 +86,10 @@ class TxStream { } else { const msg = JSON.parse(event.data) if (msg && msg.type === 'txn') { - const tx = new BitcoinTx(msg.txn) - window.dispatchEvent(new CustomEvent('bitcoin_tx', { detail: tx })) + window.dispatchEvent(new CustomEvent('bitcoin_tx', { detail: msg.txn })) } else if (msg && msg.type === 'block') { - const block = new BitcoinBlock(msg.block) - console.log('Block recieved: ', block) - window.dispatchEvent(new CustomEvent('bitcoin_block', { detail: block })) + console.log('Block recieved: ', msg.block) + window.dispatchEvent(new CustomEvent('bitcoin_block', { detail: msg.block })) } else { console.log('unknown message from websocket: ', msg) } diff --git a/src/models/BitcoinTx.js b/src/models/BitcoinTx.js index 5694df2..6e4bd69 100644 --- a/src/models/BitcoinTx.js +++ b/src/models/BitcoinTx.js @@ -1,9 +1,10 @@ import TxView from './TxView.js' export default class BitcoinTx { - constructor ({ version, id, value, inputs, outputs, witnesses, time, block }) { + constructor ({ version, id, value, inputs, outputs, witnesses, time, block }, vertexArray) { this.version = version this.id = id + this.vertexArray = vertexArray if (inputs && outputs) { this.inputs = inputs @@ -20,6 +21,10 @@ export default class BitcoinTx { this.view = new TxView(this) } + destroy () { + if (this.view) this.view.destroy() + } + calcValue () { if (this.outputs && this.outputs.length) { return this.outputs.reduce((acc, output) => { @@ -37,7 +42,11 @@ export default class BitcoinTx { if (this.view) this.view.update(update) } + setPosition (position) { + this.position = position + } + getPosition () { - if (this.view) return this.view.getPosition() + if (this.position) return this.position } } diff --git a/src/models/TxBlockScene.js b/src/models/TxBlockScene.js index 3890b4a..9504f3a 100644 --- a/src/models/TxBlockScene.js +++ b/src/models/TxBlockScene.js @@ -1,10 +1,12 @@ import TxPoolScene from './TxPoolScene.js' export default class TxBlockScene extends TxPoolScene { - constructor ({ width, height, unit = 4, padding = 1, layer }) { - super({ width, height, unit, padding, layer }) + constructor ({ width, height, unit = 4, padding = 1, layer, blockId, controller }) { + super({ width, height, unit, padding, layer, controller }) this.heightLimit = null this.expired = false + this.layedOut = false + this.blockId = blockId } resize ({ width, height, unit, padding }) { @@ -48,7 +50,8 @@ export default class TxBlockScene extends TxPoolScene { state: 'ready' }) } - const flyToBlock = { + + this.updateTx(tx, { display: { position, size: 4, @@ -60,33 +63,44 @@ export default class TxBlockScene extends TxPoolScene { }, duration: 1500, delay: 0, - state: 'flytoblock', - next: { - display: {}, - state: 'block' - } - } - if (tx.view.state === 'pool' || tx.view.state === 'colorfade') { + state: 'block' + }) + } + + prepareTx (tx, sequence) { + const position = this.place(tx.id, sequence) + if (!tx.view.initialised) { this.updateTx(tx, { display: { layer: this.layer, + position: { + x: window.innerWidth * ((position.x - this.scene.offset.x) / this.width), + y: window.innerHeight * (1 + ((position.y - this.scene.offset.y) / this.height)) + }, + size: 8, color: { palette: 0, index: 0, alpha: 1 } }, - duration: 1000, + duration: 1500, delay: 0, - state: 'colorfade', - next: flyToBlock - }) - } else { - this.updateTx(tx, { - ...flyToBlock, - duration: 2500 + state: 'ready' }) } + this.updateTx(tx, { + display: { + layer: this.layer, + color: { + palette: 0, + index: 0, + alpha: 1 + } + }, + duration: 2000, + delay: 0 + }) } expireTx (tx) { @@ -103,9 +117,30 @@ export default class TxBlockScene extends TxPoolScene { }) } + prepareAll () { + this.resize({}) + this.scene.count = 0 + let ids = this.getHiddenTxList() + for (let i = 0; i < ids.length; i++) { + this.txs[ids[i]] = this.hiddenTxs[ids[i]] + delete this.hiddenTxs[ids[i]] + } + ids = this.getActiveTxList() + for (let i = 0; i < ids.length; i++) { + this.prepareTx(this.txs[ids[i]], this.scene.count++) + } + } + layoutAll () { this.resize({}) - super.layoutAll(4) + super.layoutAll() + } + + initialLayout () { + this.prepareAll() + setTimeout(() => { + this.layoutAll() + }, 2000) } expire () { @@ -114,5 +149,12 @@ export default class TxBlockScene extends TxPoolScene { for (let i = 0; i < ids.length; i++) { this.expireTx(this.txs[ids[i]]) } + setTimeout(() => { + const txIds = this.getTxList() + for (let i = 0; i < txIds.length; i++) { + if (this.txs[txIds[i]]) this.controller.destroyTx(txIds[i]) + } + this.controller.destroyBlock(this.blockId) + }, 3000) } } diff --git a/src/models/TxPoolScene.js b/src/models/TxPoolScene.js index a1ced3d..9b9611d 100644 --- a/src/models/TxPoolScene.js +++ b/src/models/TxPoolScene.js @@ -1,14 +1,20 @@ export default class TxPoolScene { - constructor ({ width, height, unit, padding, layer }) { - this.init({ width, height, unit, padding, layer }) + constructor ({ width, height, unit, padding, layer, controller }) { + this.init({ width, height, unit, padding, layer, controller }) } - init ({ width, height, unit = 8, padding = 1, layer }) { + init ({ width, height, unit = 8, padding = 1, layer, controller }) { + this.controller = controller this.layer = layer this.resize({ width, height, unit, padding }) this.txs = {} this.hiddenTxs = {} - this.heightLimit = this.height - 50 + + this.heightLimit = Math.max(150, height / 4) + this.heightBound = this.height - this.heightLimit + this.poolTop = this.height + this.poolBottom = this.height + this.scene = { width: width, height: height, @@ -21,6 +27,8 @@ export default class TxPoolScene { } this.scrollRateLimitTimer = null this.initialised = true + + console.log('pool', this) } resize ({ width, height, unit, padding }) { @@ -37,14 +45,8 @@ export default class TxPoolScene { if (tx) tx.updateView(update) } - scroll (offset) { - if (!this.scrollRateLimitTimer || Date.now() < (this.scrollRateLimitTimer + 10000)) { - console.log(`scrolling pool by ${offset}`) - this.scrollRateLimitTimer = Date.now() - this.doScroll(offset) - } else { - console.log('scroll recharging') - } + getPoolHeight () { + return this.poolBottom - this.poolTop } insert (tx, autoLayout=true) { @@ -56,36 +58,81 @@ export default class TxPoolScene { } } + clearOffscreenTx (tx) { + const currentTargetPosition = tx.getPosition() + if (currentTargetPosition && (currentTargetPosition.y + this.scene.scroll) > this.height + 150) { + this.controller.destroyTx(tx.id) + } + } + + clearOffscreenTxs () { + if (this.poolBottom + this.scene.scroll > (this.height + 150)) { + const ids = this.getTxList() + for (let i = 0; i < ids.length; i++) { + this.clearOffscreenTx(this.txs[ids[i]]) + } + } + } + scrollTx (tx, scrollDistance) { if (tx.view.initialised) { - let currentPosition = tx.getPosition() - this.updateTx(tx, { - display: { - position: { - y: currentPosition.y + scrollDistance - } - } - }) + let currentTargetPosition = tx.getPosition() + if (currentTargetPosition) { + this.updateTx(tx, { + display: { + position: { + y: currentTargetPosition.y + scrollDistance + } + }, + duration: 500, + minDuration: 250, + adjust: true + }) + } } } doScroll (offset) { - const ids = this.getActiveTxList() - for (let i = 0; i < ids.length; i++) { - this.scrollTx(this.txs[ids[i]], offset) - } + const ids = this.getTxList() this.scene.scroll -= offset + for (let i = 0; i < ids.length; i++) { + this.scrollTx(this.txs[ids[i]], this.scene.scroll) + } + this.clearOffscreenTxs() + } + + scroll (offset, force) { + if (!this.scrollRateLimitTimer || force || Date.now() > (this.scrollRateLimitTimer + 1000)) { + this.scrollRateLimitTimer = Date.now() + this.doScroll(offset) + } + } + + txSize (value) { + // let scale = Math.log10(value) + // let size = (scale*scale) / 5 + // let rounded = Math.pow(2, Math.ceil(Math.log2(size))) + // return Math.max(4, rounded) + return this.unitWidth } layoutTx (tx, sequence) { - const position = this.place(tx.id, sequence) - // if (this.heightLimit && position.y < this.heightLimit) this.scroll(position.y - this.heightLimit) + const rawPosition = this.place(tx.id, sequence) + const scrolledPosition = { + x: rawPosition.x, + y: rawPosition.y + this.scene.scroll + } + tx.setPosition(rawPosition) + if (this.heightLimit && scrolledPosition.y < this.heightBound) { + this.scroll(scrolledPosition.y - this.heightBound) + scrolledPosition.y = rawPosition.y + this.scene.scroll + } if (!tx.view.initialised) { this.updateTx(tx, { display: { layer: this.layer, position: { - x: position.x, + x: scrolledPosition.x, y: 0 }, size: this.unitWidth, @@ -102,8 +149,9 @@ export default class TxPoolScene { this.updateTx(tx, { display: { layer: this.layer, - position, - size: this.unitWidth, + position: scrolledPosition, + // size: this.unitWidth, + size: this.txSize(tx.value), color: { palette: 0, index: 0, @@ -112,60 +160,35 @@ export default class TxPoolScene { }, duration: 1500, delay: 0, - state: 'entering', - next: { - display: { - color: { - palette: 0, - index: 1 - } - }, - duration: 30000, - delay: 100, - state: 'colorfade', - next: { - display: {}, - state: 'pool' + state: 'pool' + }) + this.updateTx(tx, { + display: { + color: { + palette: 0, + index: 1 } - } + }, + duration: 30000, + delay: 0 }) } else { - const shuffleUpdate = { + this.updateTx(tx, { display: { - position + position: scrolledPosition }, duration: 1000, + minDuration: 1000, delay: 0, - state: 'shuffling', - next: { - display: { - color: { - index: 1 - } - }, - state: 'colorfade', - duration: 15000, - delay: 100 - } - } - // if (tx.view.state === 'pool' || tx.view.state === 'colorfade') { - // this.updateTx(tx, { - // display: { - // layer: this.layer - // }, - // duration: 2000, - // delay: 0, - // state: 'pause', - // next: shuffleUpdate - // }) - // } else { - this.updateTx(tx, shuffleUpdate) - // } + adjust: true + }) } } layoutAll () { this.scene.count = 0 + this.poolTop = Infinity + this.poolBottom = -Infinity let ids = this.getHiddenTxList() for (let i = 0; i < ids.length; i++) { this.txs[ids[i]] = this.hiddenTxs[ids[i]] @@ -175,6 +198,14 @@ export default class TxPoolScene { for (let i = 0; i < ids.length; i++) { this.layoutTx(this.txs[ids[i]], this.scene.count++) } + + if (this.heightLimit && ((this.poolTop + this.scene.scroll) < this.heightBound)) { + let scrollAmount = (this.poolTop + this.scene.scroll) - this.heightBound + this.scroll(scrollAmount, true) + } else if (this.heightLimit && ((this.poolTop + this.scene.scroll) > this.heightBound)) { + let scrollAmount = Math.min(this.scene.scroll, (this.poolTop + this.scene.scroll) - this.heightBound) + this.scroll(scrollAmount, true) + } } remove (id) { @@ -201,10 +232,16 @@ export default class TxPoolScene { } place (id, position) { - return { + const placement = { x: this.scene.offset.x + (this.paddedUnitWidth * (1 + Math.floor(position % this.blockWidth))), - y: this.scene.offset.y + this.height - (this.paddedUnitWidth * (0.5 + (Math.floor(position / this.blockWidth)))) + this.scene.scroll + y: this.scene.offset.y + this.height - (this.paddedUnitWidth * (1 + (Math.floor(position / this.blockWidth)))) } + if (placement.y < this.poolTop) { + this.poolTop = placement.y + } else if (placement.y > this.poolBottom) { + this.poolBottom = placement.y + } + return placement } getVertexData () { diff --git a/src/models/TxSprite.js b/src/models/TxSprite.js index 2aca5ac..244d557 100644 --- a/src/models/TxSprite.js +++ b/src/models/TxSprite.js @@ -1,105 +1,132 @@ -function interpolateAnimationState (from, to, progress) { - const clampedProgress = Math.max(0,Math.min(1,progress)) - return Object.keys(from).reduce((result, key) => { - if (to[key] != null) { - result[key] = from[key] + ((to[key] - from[key]) * clampedProgress) - } - return result - }, from) +function interpolateAttributeStart(attribute, now, label) { + if (attribute.v == 0 || (attribute.t + attribute.d) <= now) { + // transition finished, next transition starts from current end state + // (clamp to 1) + attribute.a = attribute.b + attribute.v = 0 + attribute.d = 0 + } else if (attribute.t > now) { + // transition not started + // (clamp to 0) + } else { + // transition in progress + // (interpolate) + let progress = (now - attribute.t) + attribute.a = attribute.a + ((progress / attribute.d) * (attribute.b - attribute.a)) + attribute.d = attribute.d - progress + attribute.v = 1 / attribute.d + } } export default class TxSprite { - constructor({ now, id, value, layer, position, size, palette, color, alpha }) { + constructor({ now = Date.now(), id, value, layer, position, size, palette, color, alpha }, vertexArray) { this.id = id this.value = value this.layer = layer + this.vertexArray = vertexArray - this.duration = 0 - this.v = 0 - this.start = now + this.attributes = { + x: { a: position.x, b: position.x, t: now, v: 0, d: 0 }, + y: { a: position.y, b: position.y, t: now, v: 0, d: 0 }, + r: { a: size, b: size, t: now, v: 0, d: 0 }, + p: { a: palette, b: palette, t: now, v: 0, d: 0 }, + c: { a: color, b: color, t: now, v: 0, d: 0 }, + a: { a: alpha, b: alpha, t: now, v: 0, d: 0 }, + } - this.from = { - x: position.x, - y: position.y, + this.vertexPointer = this.vertexArray.insert(this) + + this.compile() + } + + update({ now, layer, position, size, palette, color, alpha, duration, minDuration, adjust }) { + const v = duration > 0 ? (1 / duration) : 0 + + let update = { + x: position ? position.x : null, + y: position ? position.y: null, r: size, p: palette, c: color, a: alpha } - this.to = { - ...this.from - } - this.compile() - } - - update({ now, duration, layer, position, size, palette, color, alpha }) { - // Save a copy of the current target display - const currentTarget = this.to - - // Check if we're mid-transition - const progress = (this.duration && this.start) ? ((now - this.start) / this.duration) : 0 - if (progress >= 1 || progress <= 0) { - // Transition finished or hasn't started: - // so next transition starts from the current display state - this.from = { - ...currentTarget + for (const key of Object.keys(update)) { + // for each non-null attribute: + if (update[key] != null) { + // calculate current interpolated value, and set as 'from' + interpolateAttributeStart(this.attributes[key], now, key) + // set 'start' to now + this.attributes[key].t = now + // if 'adjust' flag set + // set 'duration' to Max(remaining time, 'duration') + if (!adjust || (duration && this.attributes[key].d == 0)) { + this.attributes[key].v = v + this.attributes[key].d = duration + } else if (minDuration > this.attributes[key].d) { + this.attributes[key].v = 1 / minDuration + this.attributes[key].d = minDuration + } + // set 'to' to target value + this.attributes[key].b = update[key] } - } else { - // Mid-transition: - // we want to start the next transition from our current intermediate state - // so find that by interpolating between the last transitions start and end states - this.from = interpolateAnimationState(this.from, currentTarget, progress) } - // Build new target display, inheriting from the current target where necessary - this.to = { - x: (position && position.x != null) ? position.x : this.from.x, - y: (position && position.y != null) ? position.y : this.from.y, - r: (size != null) ? size : this.from.r, - p: (palette != null) ? palette : this.from.p, - c: (color != null) ? color : this.from.c, - a: (alpha != null) ? alpha : this.from.a - } - - if (layer != null) this.layer = layer - if (duration != null) { - this.duration = duration - this.v = duration ? (1 / duration) : 0 - } - this.start = now - this.compile() } compile () { this.vertexData = [ - this.start, - this.layer, - this.v, - this.from.x, - this.from.y, - this.to.x, - this.to.y, - this.from.r, - this.to.r, - this.from.p, - this.to.p, - this.from.c, - this.to.c, - this.from.a, - this.to.a, + this.attributes.x.a, + this.attributes.x.b, + this.attributes.x.t, + this.attributes.x.v, + + this.attributes.y.a, + this.attributes.y.b, + this.attributes.y.t, + this.attributes.y.v, + + this.attributes.r.a, + this.attributes.r.b, + this.attributes.r.t, + this.attributes.r.v, + + this.attributes.p.a, + this.attributes.p.b, + this.attributes.p.t, + this.attributes.p.v, + + this.attributes.c.a, + this.attributes.c.b, + this.attributes.c.t, + this.attributes.c.v, + + this.attributes.a.a, + this.attributes.a.b, + this.attributes.a.t, + this.attributes.a.v, ] + this.vertexArray.setData(this.vertexPointer, this.vertexData) + } + + moveVertexPointer (index) { + this.vertexPointer = index } getPosition () { return { - x: this.to.x, - y: this.to.y + x: this.attributes.x.b, + y: this.attributes.y.b } } getVertexData () { return this.vertexData } + + destroy () { + this.vertexArray.remove(this.vertexPointer) + this.vertexPointer = null + } } diff --git a/src/models/TxView.js b/src/models/TxView.js index 7f0bd5e..a3ee6df 100644 --- a/src/models/TxView.js +++ b/src/models/TxView.js @@ -2,21 +2,28 @@ import TxSprite from './TxSprite.js' import { timeOffset } from '../utils/time.js' // converts from this class's update format to TxSprite's update format -function toSpriteUpdate(display, duration, start) { +// now, id, value, layer, position, size, palette, color, alpha, duration, adjust +function toSpriteUpdate(display, duration, start, adjust) { return { now: start ? start - timeOffset : Date.now() - timeOffset, - duration, + duration: duration, ...display, - ...(display.color ? { palette: display.color.palette, color: display.color.index, alpha: display.color.alpha } : { color: null }) + ...(display.color ? { palette: display.color.palette, color: display.color.index, alpha: display.color.alpha } : { color: null }), + adjust } } export default class TxView { - constructor ({ id, time, value }) { + constructor ({ id, time, value, vertexArray }) { this.id = id this.time = time this.value = value this.initialised = false + this.vertexArray = vertexArray + } + + destroy () { + if (this.sprite) this.sprite.destroy() } /* @@ -30,28 +37,18 @@ export default class TxView { duration: of the tweening animation from the previous display state delay: for queued transitions, how long to wait after current transition completes to start. - next: another transition to animate after this one completes - (you can nest several transitions like this) - Performing another direct update cancels any pending transitions */ - update ({ display, duration, delay, state, next, start }) { - if (next) { - if (this.awaiting) clearTimeout(this.awaiting) - this.awaiting = setTimeout(() => { - this.update(next) - }, (duration || 0) + (next.delay || 0)) - } - + update ({ display, duration, delay, state, start, adjust }) { this.state = state - if (!this.initialised) { + if (!this.initialised || !this.sprite) { this.initialised = true const update = toSpriteUpdate(display, duration, start) update.id = this.id update.value = this.value - this.sprite = new TxSprite(update) + this.sprite = new TxSprite(update, this.vertexArray) } else { - const update = toSpriteUpdate(display, duration, start) + const update = toSpriteUpdate(display, duration, start, adjust) this.sprite.update(update) } } diff --git a/src/shaders/tx.vert b/src/shaders/tx.vert index 5a27f27..72f21a0 100644 --- a/src/shaders/tx.vert +++ b/src/shaders/tx.vert @@ -1,17 +1,16 @@ varying lowp vec4 vColor; -attribute float startTime; -attribute float zIndex; -attribute float speed; -attribute vec4 positions; -attribute vec2 sizes; -attribute vec2 colors; -attribute vec2 palettes; -attribute vec2 alphas; +// each attribute contains [x: startValue, y: endValue, z: startTime, w: rate] +// shader interpolates between start and end values at the given rate, from the given time +attribute vec4 posX; +attribute vec4 posY; +attribute vec4 sizes; +attribute vec4 colors; +attribute vec4 palettes; +attribute vec4 alphas; uniform vec2 screenSize; uniform float now; -// uniform float opacityTarget; // target opacity for fading points uniform sampler2D colorTexture; vec3 selectPalette(float index) { @@ -22,17 +21,20 @@ vec3 selectPalette(float index) { } } +float interpolateAttribute(vec4 attr) { + float delta = clamp((now - attr.z) * attr.w, 0.0, 1.0); + return mix(attr.x, attr.y, delta); +} + void main() { vec4 screenTransform = vec4(2.0 / screenSize.x, -2.0 / screenSize.y, -1.0, 1.0); - float delta = clamp((now - startTime) * speed, 0.0, 1.0); + vec2 position = vec2(interpolateAttribute(posX), interpolateAttribute(posY)); + gl_PointSize = interpolateAttribute(sizes); + gl_Position = vec4(position * screenTransform.xy + screenTransform.zw, 1.0, 1.0); - vec2 position = mix(positions.xy, positions.zw, delta); - gl_PointSize = mix(sizes.x, sizes.y, delta); - gl_Position = vec4(position * screenTransform.xy + screenTransform.zw, zIndex, 1.0); - - float colorIndex = mix(colors.x, colors.y, delta); - float alpha = mix(alphas.x, alphas.y, delta); + float colorIndex = interpolateAttribute(colors); + float alpha = interpolateAttribute(alphas); if (palettes.y < 1.0) { // texture color vec4 texel = texture2D(colorTexture, vec2(colorIndex, 0.0)); vColor = vec4(texel.rgb, alpha); diff --git a/src/stores.js b/src/stores.js index dae3be9..3912858 100644 --- a/src/stores.js +++ b/src/stores.js @@ -296,3 +296,4 @@ function createCounter () { } export const txQueueLength = createCounter() +export const txCount = createCounter() diff --git a/src/utils/memory.js b/src/utils/memory.js new file mode 100644 index 0000000..54a2b8b --- /dev/null +++ b/src/utils/memory.js @@ -0,0 +1,112 @@ +import { txCount } from '../stores.js' + +/* + Utility class for access and management of low-level sprite data + + Maintains a single Float32Array of sprite data, keeping track of empty slots + to allow constant-time insertion and deletion + + Automatically resizes by copying to a new, larger Float32Array when necessary, + or compacting into a smaller Float32Array when there's space to do so. +*/ +export class FastVertexArray { + constructor (length, stride) { + // console.log(`Creating Fast Vertex Array with length ${length} and stride ${stride} `) + this.length = length + this.count = 0 + this.stride = stride + this.sprites = [] + this.data = new Float32Array(this.length * this.stride) + this.freeSlots = [] + this.lastSlot = 0 + this.nullSprite = new Float32Array(this.stride) + // this.print() + } + + print () { + // console.log(`Length: ${this.length}, Free slots: ${this.freeSlots.length}, last slot: ${this.lastSlot}`) + // console.log(this.freeSlots) + // console.log(this.data) + } + + insert (sprite) { + // console.log('inserting into FVA') + this.count++ + txCount.increment() + + let position + if (this.freeSlots.length) { + position = this.freeSlots.shift() + } else { + position = this.lastSlot + this.lastSlot++ + if (this.lastSlot > this.length) { + this.expand() + } + } + // this.print() + this.sprites[position] = sprite + return position + } + + remove (index) { + this.count-- + txCount.decrement() + this.setData(index, this.nullSprite) + this.freeSlots.push(index) + this.sprites[index] = null + if (this.length > 2048 && this.count < (this.length * 0.4)) this.compact() + // this.print() + } + + setData (index, dataChunk) { + // console.log(`Updating chunk at ${index} (${index * this.stride})`) + this.data.set(dataChunk, (index * this.stride)) + // this.print() + } + + getData (index) { + return this.data.subarray(index, this.stride) + } + + expand () { + // console.log('Expanding FVA') + this.length *= 2 + const newData = new Float32Array(this.length * this.stride) + newData.set(this.data) + this.data = newData + this.print() + } + + compact () { + // console.log('Compacting FVA') + // console.log(this.sprites) + // New array length is the smallest power of 2 larger than the sprite count + const newLength = Math.pow(2, Math.ceil(Math.log2(this.count))) + if (this.newLength != this.length) { + // console.log(`compacting from ${this.length} to ${newLength}`) + this.length = newLength + this.data = new Float32Array(this.length * this.stride) + let sprite + const newSprites = [] + let i = 0 + for (var index in this.sprites) { + sprite = this.sprites[index] + if (sprite) { + newSprites.push(sprite) + sprite.moveVertexPointer(i) + sprite.compile() + i++ + } + } + this.sprites = newSprites + this.freeSlots = [] + this.lastSlot = i + } + this.print() + } + + getVertexData () { + return this.data + } +}