Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

predict x to increase WebGL accuracy #75

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion demo/large_data_range.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
</head>
<body>
<p>Test if we have precision issue with very large X</p>
<p>Segment 1 starts from 3685 June 13th 9AM</p>
<div id="chart" style="width: 100%; height: 640px;"></div>

<script src="https://cdn.jsdelivr.net/npm/d3-array@3"></script>
Expand Down Expand Up @@ -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 },
Expand Down
3 changes: 3 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ const defaultSeriesOptions = {
visible: true,
lineType: LineType.Line,
stepLocation: 1.,

xStep: 0,
xStepCorrection: true,
} as const;

type TPluginStates<TPlugins> = { [P in keyof TPlugins]: TPlugins[P] extends TimeChartPlugin<infer TState> ? TState : never };
Expand Down
12 changes: 12 additions & 0 deletions src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
129 changes: 107 additions & 22 deletions src/plugins/lineChart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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'),
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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'),
Expand All @@ -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);
Expand All @@ -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.
Expand All @@ -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;
}
}
Expand All @@ -194,14 +261,18 @@ 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

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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand All @@ -468,6 +550,7 @@ export class LineChartRenderer {
}
}

// returns modelTranslateX
syncDomain() {
this.syncViewport();
const m = this.model;
Expand All @@ -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];
}
}

Expand Down