From 3275280adcb072febceaa207b5a6039b3ff44ef1 Mon Sep 17 00:00:00 2001 From: Jamie Wong Date: Sun, 3 Dec 2017 23:22:34 -0800 Subject: [PATCH] Rework vector spaces for super sharp rendering! --- flamechart.tsx | 251 ++++++++++++++++++++++++++++--------------------- math.ts | 8 ++ 2 files changed, 153 insertions(+), 106 deletions(-) diff --git a/flamechart.tsx b/flamechart.tsx index 58e3303..0b4cd95 100644 --- a/flamechart.tsx +++ b/flamechart.tsx @@ -187,13 +187,16 @@ const DEVICE_PIXEL_RATIO = window.devicePixelRatio export class FlamechartView extends Component { renderer: ReglCommand | null = null + ctx: WebGLRenderingContext | null = null canvas: HTMLCanvasElement | null = null + overlayCanvas: HTMLCanvasElement | null = null overlayCtx: CanvasRenderingContext2D | null = null - worldSpaceViewportRect = new Rect() + configSpaceViewportRect = new Rect() labels: FlamechartFrameLabel[] = [] + hoveredLabel: FlamechartFrameLabel | null = null private preprocess() { if (!this.canvas) return @@ -206,7 +209,6 @@ export class FlamechartView extends Component { const duration = flamechart.getDuration() const maxStackHeight = layers.length - const configSpaceToWorldSpace = this.configSpaceToWorldSpace() const frameColors = flamechart.getFrameColors() for (let i = 0; i < layers.length; i++) { @@ -226,11 +228,6 @@ export class FlamechartView extends Component { } } - this.worldSpaceViewportRect = new Rect( - new Vec2(0, 0), - new Vec2(this.viewSpaceViewportWidth(), this.viewSpaceViewportHeight()) - ) - this.ctx = this.canvas.getContext('webgl')! this.renderer = rectangleBatchRenderer(this.ctx, configSpaceRects, colors) } @@ -256,53 +253,42 @@ export class FlamechartView extends Component { } } - private configSpaceWidth() { return this.props.flamechart.getDuration() } - private configSpaceHeight() { return this.props.flamechart.getLayers().length } - private configSpaceSize() { return new Vec2(this.configSpaceWidth(), this.configSpaceHeight()) } - - private WORLD_SPACE_FRAME_HEIGHT = 16 - private WORLD_SPACE_LABEL_FONT_SIZE = 12 - - private viewSpaceViewportWidth() { return this.canvas ? this.canvas.width : 0 } - private viewSpaceViewportHeight() { return this.canvas ? this.canvas.height : 0 } - private viewSpaceViewportSize() { return new Vec2(this.viewSpaceViewportWidth(), this.viewSpaceViewportHeight()) } - - private configSpaceToWorldSpace() { - return AffineTransform.withScale(new Vec2( - this.viewSpaceViewportWidth() / this.configSpaceWidth(), - this.WORLD_SPACE_FRAME_HEIGHT - )) - } - private worldSpaceSize() { - return this.configSpaceToWorldSpace().transformVector(this.configSpaceSize()) - } - - private worldSpaceToViewSpace() { - return AffineTransform.betweenRects( - this.worldSpaceViewportRect, - new Rect(new Vec2(0, 0), this.viewSpaceViewportSize()) + private configSpaceSize() { + return new Vec2( + this.props.flamechart.getDuration(), + this.props.flamechart.getLayers().length ) } - private viewSpaceToNDC() { + private physicalViewSize() { + return new Vec2( + this.canvas ? this.canvas.width : 0, + this.canvas ? this.canvas.height : 0 + ) + } + + private LOGICAL_VIEW_SPACE_FRAME_HEIGHT = 16 + private LOGICAL_VIEW_SPACE_LABEL_FONT_SIZE = 12 + + private configSpaceToPhysicalViewSpace() { + return AffineTransform.betweenRects( + this.configSpaceViewportRect, + new Rect(new Vec2(0, 0), this.physicalViewSize()) + ) + } + private physicalViewSpaceToNDC() { return AffineTransform.withScale(new Vec2(1, -1)).times( AffineTransform.betweenRects( - new Rect(new Vec2(0, 0), this.viewSpaceViewportSize()), + new Rect(new Vec2(0, 0), this.physicalViewSize()), new Rect(new Vec2(-1, -1), new Vec2(2, 2)) ) ) } - private viewSpaceToOverlaySpace() { + private logicalToPhysicalViewSpace() { return AffineTransform.withScale(new Vec2(DEVICE_PIXEL_RATIO, DEVICE_PIXEL_RATIO)) } - private configSpaceToNDC() { - return this.viewSpaceToNDC() - .times(this.worldSpaceToViewSpace()) - .times(this.configSpaceToWorldSpace()) - } - private resizeOverlayCanvasIfNeeded() { if (!this.overlayCanvas) return let {width, height} = this.overlayCanvas.getBoundingClientRect() @@ -332,55 +318,66 @@ export class FlamechartView extends Component { if (!ctx) return this.resizeOverlayCanvasIfNeeded() - const configSpaceToOverlaySpace = this.viewSpaceToOverlaySpace() - .times(this.worldSpaceToViewSpace()) - .times(this.configSpaceToWorldSpace()) + const configToPhysical = this.configSpaceToPhysicalViewSpace() - const overlaySpaceFontSize = this.WORLD_SPACE_LABEL_FONT_SIZE * DEVICE_PIXEL_RATIO - const overlaySpaceFrameHeight = this.WORLD_SPACE_FRAME_HEIGHT * DEVICE_PIXEL_RATIO + const physicalViewSpaceFontSize = this.LOGICAL_VIEW_SPACE_LABEL_FONT_SIZE * DEVICE_PIXEL_RATIO + const physicalViewSpaceFrameHeight = this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT * DEVICE_PIXEL_RATIO - ctx.font = `${overlaySpaceFontSize}px/${overlaySpaceFrameHeight}px Courier, monospace` + const minWidthToRender = cachedMeasureTextWidth(ctx, 'M' + ELLIPSIS + 'M') + const physicalViewSize = this.physicalViewSize() + const physicalViewBounds = new Rect(new Vec2(0, 0), physicalViewSize) + + ctx.clearRect(0, 0, physicalViewSize.x, physicalViewSize.y) + + ctx.strokeStyle = 'rgba(15, 10, 5, 0.5)' + ctx.lineWidth = 2 + + if (this.hoveredLabel) { + const physicalViewBounds = configToPhysical.transformRect(this.hoveredLabel.configSpaceBounds) + ctx.strokeRect( + Math.floor(physicalViewBounds.left()), Math.floor(physicalViewBounds.top()), + Math.floor(physicalViewBounds.width()), Math.floor(physicalViewBounds.height()) + ) + } + + ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px Courier, monospace` ctx.fillStyle = 'rgba(15, 10, 5, 1)' ctx.textBaseline = 'top' - const minWidthToRender = cachedMeasureTextWidth(ctx, 'M' + ELLIPSIS + 'M') - const overlaySpaceViewportRect = this.viewSpaceToOverlaySpace().transformRect(new Rect(new Vec2(0, 0), this.viewSpaceViewportSize())) - - ctx.clearRect(overlaySpaceViewportRect.left(), overlaySpaceViewportRect.top(), overlaySpaceViewportRect.width(), overlaySpaceViewportRect.height()) - for (let label of this.labels) { const LABEL_PADDING_PX = 2 * DEVICE_PIXEL_RATIO - let overlaySpaceBounds = configSpaceToOverlaySpace.transformRect(label.configSpaceBounds) + let physicalLabelBounds = configToPhysical.transformRect(label.configSpaceBounds) - overlaySpaceBounds = overlaySpaceBounds - .withOrigin(overlaySpaceBounds.origin.plus(new Vec2(LABEL_PADDING_PX, LABEL_PADDING_PX))) - .withSize(overlaySpaceBounds.size.minus(new Vec2(2 * LABEL_PADDING_PX, 2 * LABEL_PADDING_PX))) + physicalLabelBounds = physicalLabelBounds + .withOrigin(physicalLabelBounds.origin.plus(new Vec2(LABEL_PADDING_PX, LABEL_PADDING_PX))) + .withSize(physicalLabelBounds.size.minus(new Vec2(2 * LABEL_PADDING_PX, 2 * LABEL_PADDING_PX))) .intersectWith(new Rect( - new Vec2(LABEL_PADDING_PX + overlaySpaceViewportRect.origin.x, -Infinity), - new Vec2(overlaySpaceViewportRect.size.x, Infinity) + new Vec2(LABEL_PADDING_PX, -Infinity), + new Vec2(physicalViewSize.x, Infinity) )) - if (overlaySpaceBounds.width() < minWidthToRender) continue + if (physicalLabelBounds.width() < minWidthToRender) continue // Cull text outside the viewport - if (overlaySpaceViewportRect.intersectWith(overlaySpaceBounds).isEmpty()) continue + if (physicalViewBounds.intersectWith(physicalLabelBounds).isEmpty()) continue - if (overlaySpaceBounds.origin.x < 0) { - overlaySpaceBounds = overlaySpaceBounds.withOrigin( - new Vec2(0, overlaySpaceBounds.origin.y) + if (physicalLabelBounds.origin.x < 0) { + physicalLabelBounds = physicalLabelBounds.withOrigin( + new Vec2(0, physicalLabelBounds.origin.y) ) } - const trimmedText = trimTextMid(ctx, label.frame.name, overlaySpaceBounds.width()) - ctx.fillText(trimmedText, overlaySpaceBounds.left(), overlaySpaceBounds.top()) + const trimmedText = trimTextMid(ctx, label.frame.name, physicalLabelBounds.width()) + ctx.fillText(trimmedText, physicalLabelBounds.left(), physicalLabelBounds.top()) } } private resizeCanvasIfNeeded() { if (!this.canvas || !this.ctx) return - let {width, height} = this.canvas.getBoundingClientRect() - width = Math.floor(width) - height = Math.floor(height) + let { width, height } = this.canvas.getBoundingClientRect() + const logicalHeight = height + width = Math.floor(width) * DEVICE_PIXEL_RATIO + height = Math.floor(height) * DEVICE_PIXEL_RATIO // Still initializing: don't resize yet if (width === 0 || height === 0) return @@ -393,14 +390,21 @@ export class FlamechartView extends Component { this.canvas.width = width this.canvas.height = height - // Resize the world-space viewport rectangle to match the - // window size. - this.worldSpaceViewportRect = this.worldSpaceViewportRect.withSize( - this.worldSpaceViewportRect.size.timesPointwise(new Vec2( - width / oldWidth, - height / oldHeight - )) - ) + if (this.configSpaceViewportRect.isEmpty()) { + this.configSpaceViewportRect = new Rect( + new Vec2(0, 0), + new Vec2(this.configSpaceSize().x, logicalHeight / this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT) + ) + } else { + // Resize the viewport rectangle to match the window size aspect + // ratio. + this.configSpaceViewportRect = this.configSpaceViewportRect.withSize( + this.configSpaceViewportRect.size.timesPointwise(new Vec2( + width / oldWidth, + height / oldHeight + )) + ) + } this.ctx.viewport(0, 0, width, height) } @@ -408,8 +412,11 @@ export class FlamechartView extends Component { if (!this.renderer || !this.canvas) return this.resizeCanvasIfNeeded() + const configSpaceToNDC = this.physicalViewSpaceToNDC().times(this.configSpaceToPhysicalViewSpace()) + this.renderer({ - configSpaceToNDC: this.configSpaceToNDC() + configSpaceToNDC: configSpaceToNDC, + physicalSize: this.physicalViewSize() }) } @@ -425,41 +432,68 @@ export class FlamechartView extends Component { } } - private pan(viewSpaceDelta: Vec2) { - const worldSpaceDelta = this.worldSpaceToViewSpace().inverseTransformVector(viewSpaceDelta) - if (!worldSpaceDelta) return - this.transformViewport(AffineTransform.withTranslation(worldSpaceDelta)) + private transformViewport(transform: AffineTransform) { + const viewportRect = transform.transformRect(this.configSpaceViewportRect) + + const configSpaceOriginBounds = new Rect( + new Vec2(0, 0), + this.configSpaceSize().minus(viewportRect.size) + ) + + const configSpaceSizeBounds = new Rect( + new Vec2(1, viewportRect.height()), + new Vec2(this.configSpaceSize().x, viewportRect.height()) + ) + + this.configSpaceViewportRect = new Rect( + configSpaceOriginBounds.closestPointTo(viewportRect.origin), + configSpaceSizeBounds.closestPointTo(viewportRect.size) + ) } - private zoom(viewSpaceZoomCenter: Vec2, multiplier: number) { - const worldSpaceZoomCenter = this.worldSpaceToViewSpace().inverseTransformPosition(viewSpaceZoomCenter) - if (!worldSpaceZoomCenter) return + private pan(logicalViewSpaceDelta: Vec2) { + const physicalDelta = this.logicalToPhysicalViewSpace().transformVector(logicalViewSpaceDelta) + const configDelta = this.configSpaceToPhysicalViewSpace().inverseTransformVector(physicalDelta) + + if (!configDelta) return + this.transformViewport(AffineTransform.withTranslation(configDelta)) + } + + private zoom(logicalViewSpaceCenter: Vec2, multiplier: number) { + const physicalCenter = this.logicalToPhysicalViewSpace().transformPosition(logicalViewSpaceCenter) + const configSpaceCenter = this.configSpaceToPhysicalViewSpace().inverseTransformPosition(physicalCenter) + + if (!configSpaceCenter) return const zoomTransform = AffineTransform - .withTranslation(worldSpaceZoomCenter.times(-1)) + .withTranslation(configSpaceCenter.times(-1)) .scaledBy(new Vec2(multiplier, 1)) - .translatedBy(worldSpaceZoomCenter) + .translatedBy(configSpaceCenter) this.transformViewport(zoomTransform) } - private transformViewport(transform: AffineTransform) { - const viewportRect = transform.transformRect(this.worldSpaceViewportRect) + private onMouseMove = (ev: MouseEvent) => { + this.hoveredLabel = null + const logicalViewSpaceMouse = new Vec2(ev.offsetX, ev.offsetY) + const physicalViewSpaceMouse = this.logicalToPhysicalViewSpace().transformPosition(logicalViewSpaceMouse) + const configSpaceMouse = this.configSpaceToPhysicalViewSpace().inverseTransformPosition(physicalViewSpaceMouse) - const worldSpaceOriginBounds = new Rect( - new Vec2(0, 0), - this.worldSpaceSize().minus(viewportRect.size) - ) + if (!configSpaceMouse) return - const worldSpaceSizeBounds = new Rect( - new Vec2(1, viewportRect.height()), - new Vec2(this.worldSpaceSize().x, viewportRect.height()) - ) + let labelUnderMouse: FlamechartFrameLabel | null = null - this.worldSpaceViewportRect = new Rect( - worldSpaceOriginBounds.closestPointTo(viewportRect.origin), - worldSpaceSizeBounds.closestPointTo(viewportRect.size) - ) + // This could be sped up significantly + for (let label of this.labels) { + if (label.configSpaceBounds.contains(configSpaceMouse)) { + this.hoveredLabel = label + break + } + } + + // TODO(jlfwong): Change this to do the more correct thing: + // debounce to rendering at most once a frame. + requestAnimationFrame(() => this.renderCanvas()) } private onWheel = (ev: WheelEvent) => { @@ -490,6 +524,7 @@ export class FlamechartView extends Component { // TODO(jlfwong): Change this to do the more correct thing: // debounce to rendering at most once a frame. requestAnimationFrame(() => this.renderCanvas()) + this.forceUpdate() } componentDidUpdate() { this.renderCanvas() } @@ -501,16 +536,12 @@ export class FlamechartView extends Component { return (
- {/* - We render text at a higher resolution then scale down to - ensure we're rendering at 1:1 device pixel ratio. - This ensures our text is rendered crisply. - */} { @@ -561,12 +593,16 @@ export const rectangleBatchRenderer = (ctx: WebGLRenderingContext, rects: Rect[] return regl(ctx)({ vert: ` uniform mat3 configSpaceToNDC; + uniform vec2 physicalSize; attribute vec2 position; attribute vec3 color; varying vec3 vColor; void main() { vColor = color; - gl_Position = vec4((configSpaceToNDC * vec3(position, 1)).xy, 0, 1); + vec2 roundedPosition = (configSpaceToNDC * vec3(position, 1)).xy; + vec2 halfSize = physicalSize / 2.0; + roundedPosition = floor(roundedPosition * halfSize) / halfSize; + gl_Position = vec4(roundedPosition, 0, 1); } `, @@ -586,6 +622,9 @@ export const rectangleBatchRenderer = (ctx: WebGLRenderingContext, rects: Rect[] uniforms: { configSpaceToNDC: (context, props) => { return props.configSpaceToNDC.flatten() + }, + physicalSize: (context, props) => { + return props.physicalSize.flatten() } }, diff --git a/math.ts b/math.ts index 4c3b2bb..2e97a45 100644 --- a/math.ts +++ b/math.ts @@ -208,6 +208,14 @@ export class Rect { ) } + distanceFrom(p: Vec2) { + return p.minus(this.closestPointTo(p)).length() + } + + contains(p: Vec2) { + return this.distanceFrom(p) === 0 + } + intersectWith(other: Rect) { const topLeft = Vec2.max(this.topLeft(), other.topLeft()) const bottomRight = Vec2.max(topLeft, Vec2.min(this.bottomRight(), other.bottomRight()))