From 1d2ec6ecfdc02db1da5eedb2710fb720f4800460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E7=8E=AE=E6=96=87?= Date: Mon, 22 Jan 2024 02:23:57 +0800 Subject: [PATCH] predict x to increase WebGL accuracy --- demo/large_data_range.html | 3 +- src/core/index.ts | 3 + src/options.ts | 12 ++++ src/plugins/lineChart.ts | 129 ++++++++++++++++++++++++++++++------- 4 files changed, 124 insertions(+), 23 deletions(-) diff --git a/demo/large_data_range.html b/demo/large_data_range.html index 2ff64d8..47575e7 100644 --- a/demo/large_data_range.html +++ b/demo/large_data_range.html @@ -8,6 +8,7 @@

Test if we have precision issue with very large X

+

Segment 1 starts from 3685 June 13th 9AM

@@ -40,7 +41,7 @@ const chart = new TimeChart(el, { baseTime: Date.now(), series: [ - { name: 'Line 1', data: data, color: 'blue' }, + { name: 'Line 1', data: data, color: 'blue', xStep: timeStep }, ], zoom: { x: { autoRange: true } }, tooltip: { enabled: true }, diff --git a/src/core/index.ts b/src/core/index.ts index ba0f758..7e57101 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -38,6 +38,9 @@ const defaultSeriesOptions = { visible: true, lineType: LineType.Line, stepLocation: 1., + + xStep: 0, + xStepCorrection: true, } as const; type TPluginStates = { [P in keyof TPlugins]: TPlugins[P] extends TimeChartPlugin ? TState : never }; diff --git a/src/options.ts b/src/options.ts index 8716b4b..39d86ee 100644 --- a/src/options.ts +++ b/src/options.ts @@ -106,6 +106,18 @@ export interface TimeChartSeriesOptions { visible: boolean; lineType: LineType; stepLocation: number; + + /** + * The expected interval of adjacent x values. Set this for higher WebGL rendering accuracy. + * @default 0 + */ + xStep: number; + /** + * Whether to correct xStep to match the actual interval when a segment is fully filled. + * @default true + * @see xStep + */ + xStepCorrection: boolean; } export function resolveColorRGBA(color: ColorSpecifier): [number, number, number, number] { diff --git a/src/plugins/lineChart.ts b/src/plugins/lineChart.ts index 22f6c36..8ddd8ee 100644 --- a/src/plugins/lineChart.ts +++ b/src/plugins/lineChart.ts @@ -25,11 +25,11 @@ class ShaderUniformData { get modelScale() { return new Float32Array(this.data, 0, 2); } - get modelTranslate() { + get projectionScale() { return new Float32Array(this.data, 2 * 4, 2); } - get projectionScale() { - return new Float32Array(this.data, 4 * 4, 2); + get modelTranslateY() { + return new Float32Array(this.data, 4 * 4, 1); } upload(index = 0) { @@ -41,9 +41,13 @@ class ShaderUniformData { const VS_HEADER = `#version 300 es layout (std140) uniform proj { vec2 modelScale; - vec2 modelTranslate; vec2 projectionScale; + float modelTranslateY; }; +uniform highp float modelTranslateX; +uniform highp float modelTranslateXStep; +uniform int startIndex; + uniform highp sampler2D uDataPoints; uniform int uLineType; uniform float uStepLocation; @@ -72,7 +76,10 @@ class NativeLineProgram extends LinkedWebGLProgram { uniform float uPointSize; void main() { - vec2 pos2d = projectionScale * modelScale * (dataPoint(gl_VertexID) + modelTranslate); + vec2 dp = dataPoint(gl_VertexID); + dp.x += modelTranslateXStep * float(gl_VertexID - startIndex); + vec2 modelTranslate = vec2(modelTranslateX, modelTranslateY); + vec2 pos2d = projectionScale * modelScale * (dp + modelTranslate); gl_Position = vec4(pos2d, 0, 1); gl_PointSize = uPointSize; } @@ -83,6 +90,9 @@ void main() { this.link(); this.locations = { + modelTranslateX: this.getUniformLocation('modelTranslateX'), + modelTranslateXStep: this.getUniformLocation('modelTranslateXStep'), + startIndex: this.getUniformLocation('startIndex'), uDataPoints: this.getUniformLocation('uDataPoints'), uPointSize: this.getUniformLocation('uPointSize'), uColor: this.getUniformLocation('uColor'), @@ -105,6 +115,8 @@ void main() { int index = gl_VertexID >> 2; vec2 dp[2] = vec2[2](dataPoint(index), dataPoint(index + 1)); + dp[0].x += modelTranslateXStep * float(index - startIndex); + dp[1].x += modelTranslateXStep * float(index + 1 - startIndex); vec2 base; vec2 off; @@ -114,13 +126,14 @@ void main() { dir = normalize(modelScale * dir); off = vec2(-dir.y, dir.x) * uLineWidth; } else if (uLineType == ${LineType.Step}) { - base = vec2(dp[0].x * (1. - uStepLocation) + dp[1].x * uStepLocation, dp[di].y); + base = vec2(mix(dp[0].x, dp[1].x, uStepLocation), dp[di].y); float up = sign(dp[0].y - dp[1].y); off = vec2(uLineWidth * up, uLineWidth); } if (side == 1) off = -off; + vec2 modelTranslate = vec2(modelTranslateX, modelTranslateY); vec2 cssPose = modelScale * (base + modelTranslate); vec2 pos2d = projectionScale * (cssPose + off); gl_Position = vec4(pos2d, 0, 1); @@ -132,6 +145,9 @@ void main() { this.link(); this.locations = { + modelTranslateX: this.getUniformLocation('modelTranslateX'), + modelTranslateXStep: this.getUniformLocation('modelTranslateXStep'), + startIndex: this.getUniformLocation('startIndex'), uDataPoints: this.getUniformLocation('uDataPoints'), uLineType: this.getUniformLocation('uLineType'), uStepLocation: this.getUniformLocation('uStepLocation'), @@ -147,11 +163,16 @@ void main() { } class SeriesSegmentVertexArray { + // X data stored in dataBuffer is offset from prediction by x0 and xStep dataBuffer; + // all the data in dataBuffer is invalidated when changing these two variables + x0 = 0.; + xStep = -1.; + constructor( private gl: WebGL2RenderingContext, - private dataPoints: DataPointsBuffer, + private series: TimeChartSeriesOptions, ) { this.dataBuffer = throwIfFalsy(gl.createTexture()); gl.bindTexture(gl.TEXTURE_2D, this.dataBuffer); @@ -165,8 +186,54 @@ class SeriesSegmentVertexArray { this.gl.deleteTexture(this.dataBuffer); } - syncPoints(start: number, n: number, bufferPos: number) { - const dps = this.dataPoints; + /** + * @param i index into `this.series.data` + */ + predictX(i: number) { + return this.x0 + this.xStep * i; + } + + /** Sync n dataPoints at this.dataPoints[start] to this.series.data[bufferPos] + * @param resync request re-sync all vaild data covered by the buffer, update X prediction + */ + syncPoints(start: number, n: number, bufferPos: number, resync = false) { + const dps = this.series.data; + if (resync) { + // TODO: skip if the prediction is already accurate enough? + const s = Math.max(start - bufferPos, 0); + start -= s; + bufferPos -= s; + const e = Math.min(start - bufferPos + BUFFER_POINT_CAPACITY, dps.length); + n = e - start; + + let step = n - 1; + let step1 = 0; + let x1 = dps[start].x; + let xn = dps[start + n - 1].x; + // if the values at the edge are valid, use the average of the overlapping values + // to ensure the predictX of adjacent segments are consistent. + if (bufferPos === 0) { + step -= 0.5; + step1 += 0.5; + x1 = (dps[start].x + dps[start + 1].x) / 2 + } + if (e === BUFFER_POINT_CAPACITY) { + step -= 0.5; + xn = (dps[start + n - 2].x + dps[start + n - 1].x) / 2 + } + this.xStep = (xn - this.x0) / step; + this.x0 = x1 - this.xStep * step1; + } else if (this.xStep < 0) { + // first sync, set x0 to match either the first or last data point, whichever is valid, + // to ensure the overlapping part with the previous segment is consistent. + // Do not guess xDelta as it may be very inaccurate. + this.xStep = this.series.xStep; + if (bufferPos <= 1) + this.x0 = (dps[start].x + dps[start + 1].x) / 2 - this.xStep * (bufferPos + 0.5); + else + this.x0 = (dps[start + n - 2].x + dps[start + n - 1].x) / 2 - this.xStep * (bufferPos + n - 1.5); + } + let rowStart = Math.floor(bufferPos / BUFFER_TEXTURE_WIDTH); let rowEnd = Math.ceil((bufferPos + n) / BUFFER_TEXTURE_WIDTH); // Ensure we have some padding at both ends of data. @@ -182,7 +249,7 @@ class SeriesSegmentVertexArray { const i = Math.max(Math.min(start + p - bufferPos, dps.length - 1), 0); const dp = dps[i]; const bufferIdx = ((r - rowStart) * BUFFER_TEXTURE_WIDTH + c) * 2; - buffer[bufferIdx] = dp.x; + buffer[bufferIdx] = dp.x - this.predictX(p); buffer[bufferIdx + 1] = dp.y; } } @@ -194,7 +261,7 @@ class SeriesSegmentVertexArray { /** * @param renderInterval [start, end) interval of data points, start from 0 */ - draw(renderInterval: { start: number, end: number }, type: LineType) { + draw(renderInterval: { start: number, end: number }, prog: lineProgram, type: LineType, translateX: number) { const first = Math.max(0, renderInterval.start); const last = Math.min(BUFFER_INTERVAL_CAPACITY, renderInterval.end) const count = last - first @@ -202,6 +269,10 @@ class SeriesSegmentVertexArray { const gl = this.gl; gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.dataBuffer); + gl.uniform1i(prog.locations.startIndex, first); + gl.uniform1f(prog.locations.modelTranslateX, translateX + this.predictX(first)); + gl.uniform1f(prog.locations.modelTranslateXStep, this.xStep); + if (type === LineType.Line) { gl.drawArrays(gl.TRIANGLE_STRIP, first * 4, count * 4 + (last !== renderInterval.end ? 2 : 0)); } else if (type === LineType.Step) { @@ -226,8 +297,13 @@ class SeriesSegmentVertexArray { class SeriesVertexArray { private segments = [] as SeriesSegmentVertexArray[]; // each segment has at least 2 points - private validStart = 0; // start position of the first segment. (0, BUFFER_INTERVAL_CAPACITY] - private validEnd = 0; // end position of the last segment. [2, BUFFER_POINT_CAPACITY) + // both ends have at least 1 empty slot, potentially filled with repeated values + // adjacent segments overlap by 2 point + + /** start position (inclusive) of the first segment. (0, BUFFER_INTERVAL_CAPACITY] */ + private validStart = 0; + /** end position (exclusive) of the last segment. [2, BUFFER_POINT_CAPACITY) */ + private validEnd = 0; constructor( private gl: WebGL2RenderingContext, @@ -267,7 +343,7 @@ class SeriesVertexArray { } private newArray() { - return new SeriesSegmentVertexArray(this.gl, this.series.data); + return new SeriesSegmentVertexArray(this.gl, this.series); } private pushFront() { let numDPtoAdd = this.series.data.pushed_front; @@ -287,9 +363,12 @@ class SeriesVertexArray { while (true) { const activeArray = this.segments[0]; const n = Math.min(this.validStart, numDPtoAdd); - activeArray.syncPoints(numDPtoAdd - n, n, this.validStart - n); - numDPtoAdd -= this.validStart - (BUFFER_POINT_CAPACITY - BUFFER_INTERVAL_CAPACITY); + const start = numDPtoAdd - n; + numDPtoAdd -= this.validStart; + numDPtoAdd += (BUFFER_POINT_CAPACITY - BUFFER_INTERVAL_CAPACITY); // each segment overlaps with the previous one this.validStart -= n; + const resync = this.validStart === 0 && this.series.xStepCorrection; + activeArray.syncPoints(start, n, this.validStart, resync); if (this.validStart > 0) break; newArray(); @@ -314,7 +393,8 @@ class SeriesVertexArray { while (true) { const activeArray = this.segments[this.segments.length - 1]; const n = Math.min(BUFFER_POINT_CAPACITY - this.validEnd, numDPtoAdd); - activeArray.syncPoints(this.series.data.length - numDPtoAdd, n, this.validEnd); + const resync = this.validEnd + n === BUFFER_POINT_CAPACITY && this.series.xStepCorrection; + activeArray.syncPoints(this.series.data.length - numDPtoAdd, n, this.validEnd, resync); // Note that each segment overlaps with the previous one. // numDPtoAdd can increase here, indicating the overlapping part should be synced again to the next segment numDPtoAdd -= BUFFER_INTERVAL_CAPACITY - this.validEnd; @@ -356,7 +436,7 @@ class SeriesVertexArray { this.pushBack(); } - draw(renderDomain: { min: number, max: number }) { + draw(renderDomain: { min: number, max: number }, prog: lineProgram, translateX: number) { const data = this.series.data; if (this.segments.length === 0 || data[0].x > renderDomain.max || data[data.length - 1].x < renderDomain.min) return; @@ -374,11 +454,13 @@ class SeriesVertexArray { this.segments[i].draw({ start: startInterval - arrOffset, end: endInterval - arrOffset, - }, this.series.lineType); + }, prog, this.series.lineType, translateX); } } } +type lineProgram = NativeLineProgram | LineProgram; + export class LineChartRenderer { private lineProgram = new LineProgram(this.gl, this.options.debugWebGL); private nativeLineProgram = new NativeLineProgram(this.gl, this.options.debugWebGL); @@ -428,7 +510,7 @@ export class LineChartRenderer { drawFrame() { this.syncBuffer(); - this.syncDomain(); + const transX = this.syncDomain(); this.uniformBuffer.upload(); const gl = this.gl; for (const [ds, arr] of this.arrays) { @@ -458,7 +540,7 @@ export class LineChartRenderer { min: this.model.xScale.invert(this.options.renderPaddingLeft - lineWidth / 2), max: this.model.xScale.invert(this.width - this.options.renderPaddingRight + lineWidth / 2), }; - arr.draw(renderDomain); + arr.draw(renderDomain, prog, transX); } if (this.options.debugWebGL) { const err = gl.getError(); @@ -468,6 +550,7 @@ export class LineChartRenderer { } } + // returns modelTranslateX syncDomain() { this.syncViewport(); const m = this.model; @@ -492,7 +575,9 @@ export class LineChartRenderer { ]; this.uniformBuffer.modelScale.set(s); - this.uniformBuffer.modelTranslate.set(t); + this.uniformBuffer.modelTranslateY.set(t.slice(1)); + + return t[0]; } }