Skip to content
Closed
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
93 changes: 85 additions & 8 deletions shadow-agent/src/renderer/canvas/CanvasRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
createQualityController,
getQualityProfile,
sampleQualityController,
selectParticleRenderMode,
type QualityChangeReason,
type QualityControllerState,
type QualityTier,
Expand All @@ -23,7 +24,6 @@ import {
import {
COLLIDE_RADIUS,
STATE_COLORS,
type Particle,
type RiskLevel,
type SimulationEdge,
type SimulationNode
Expand All @@ -38,6 +38,11 @@ import {
drawShadowNode
} from './draw-utils';
import { tickCanvasPulses } from './canvas-pulse';
import {
createWebglParticleRenderer,
type ParticleDrawMode,
type WebglParticleRenderer
} from './webgl-particle-renderer';
export { triggerCanvasPulse, clearCanvasPulses } from './canvas-pulse';
export type { CanvasPulseKind } from './canvas-pulse';

Expand Down Expand Up @@ -119,12 +124,14 @@ export interface CanvasRendererProps {

export default function CanvasRenderer({ agentNodes, riskLevel, latestInsight }: CanvasRendererProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const particleCanvasRef = useRef<HTMLCanvasElement>(null);
const animationFrameRef = useRef<number>(0);
const lastFrameRef = useRef<number | null>(null);
const nodesRef = useRef<SimulationNode[]>([]);
const edgesRef = useRef<SimulationEdge[]>([]);
const edgesByIdRef = useRef<Map<string, SimulationEdge>>(new Map());
const simulationRef = useRef<Simulation<SimulationNode, SimulationEdge> | null>(null);
const webglParticleRendererRef = useRef<WebglParticleRenderer | null>(null);
const riskLevelRef = useRef<RiskLevel | undefined>(riskLevel);
const latestInsightRef = useRef<ShadowInsight | undefined>(latestInsight);
const qualityStateRef = useRef<QualityControllerState>(
Expand All @@ -144,10 +151,12 @@ export default function CanvasRenderer({ agentNodes, riskLevel, latestInsight }:
tier: QualityTier;
reason: QualityChangeReason;
particleMode: 'worker' | 'inline';
particleDrawMode: ParticleDrawMode | 'disabled';
}>({
tier: qualityStateRef.current.tier,
reason: qualityStateRef.current.lastChangeReason,
particleMode: particleEngineRef.current.mode
particleMode: particleEngineRef.current.mode,
particleDrawMode: 'canvas'
});

const collectMetrics = useCallback((particleCount: number): ResourceMetrics => {
Expand Down Expand Up @@ -183,7 +192,8 @@ export default function CanvasRenderer({ agentNodes, riskLevel, latestInsight }:
setRuntimeHud({
tier: nextState.tier,
reason: nextState.lastChangeReason,
particleMode: particleEngineRef.current.mode
particleMode: particleEngineRef.current.mode,
particleDrawMode: webglParticleRendererRef.current?.mode ?? 'canvas'
});
}
}, []);
Expand Down Expand Up @@ -236,7 +246,33 @@ export default function CanvasRenderer({ agentNodes, riskLevel, latestInsight }:
}
}

drawParticles(ctx, particleSnapshot, nodesById, edgesByIdRef.current, profile.tier);
const selectedParticleRenderMode = selectParticleRenderMode(profile.tier, webglParticleRendererRef.current !== null);
let particleDrawMode: ParticleDrawMode | 'disabled' = 'disabled';

if (selectedParticleRenderMode === 'webgl') {
const drewParticlesOnGpu = webglParticleRendererRef.current?.draw({
particles: particleSnapshot,
nodesById,
edgesById: edgesByIdRef.current,
qualityTier: profile.tier,
width: viewport.width,
height: viewport.height,
dpr: viewport.dpr
}) ?? false;

if (drewParticlesOnGpu) {
particleDrawMode = 'webgl';
} else {
drawParticles(ctx, particleSnapshot, nodesById, edgesByIdRef.current, profile.tier);
particleDrawMode = 'canvas';
}
} else if (selectedParticleRenderMode === 'canvas2d') {
webglParticleRendererRef.current?.clear();
drawParticles(ctx, particleSnapshot, nodesById, edgesByIdRef.current, profile.tier);
particleDrawMode = 'canvas';
} else {
webglParticleRendererRef.current?.clear();
}

for (const node of nodesRef.current) {
drawAgentNode(ctx, node, time, profile.tier);
Expand All @@ -262,6 +298,21 @@ export default function CanvasRenderer({ agentNodes, riskLevel, latestInsight }:
}
}

setRuntimeHud((current) => {
const nextHud = {
tier: qualityStateRef.current.tier,
reason: qualityStateRef.current.lastChangeReason,
particleMode: particleEngine.mode,
particleDrawMode
};
return current.tier === nextHud.tier &&
current.reason === nextHud.reason &&
current.particleMode === nextHud.particleMode &&
current.particleDrawMode === nextHud.particleDrawMode
? current
: nextHud;
});

animationFrameRef.current = requestAnimationFrame(draw);
}, [applyQualityState, collectMetrics]);

Expand Down Expand Up @@ -343,6 +394,13 @@ export default function CanvasRenderer({ agentNodes, riskLevel, latestInsight }:
}

syncCanvasToDisplaySize(canvas, qualityStateRef.current.profile);
webglParticleRendererRef.current = particleCanvasRef.current
? createWebglParticleRenderer(particleCanvasRef.current)
: null;
setRuntimeHud((current) => ({
...current,
particleDrawMode: webglParticleRendererRef.current?.mode ?? 'canvas'
}));
animationFrameRef.current = requestAnimationFrame(draw);

const resizeObserver = new ResizeObserver(() => {
Expand All @@ -364,13 +422,31 @@ export default function CanvasRenderer({ agentNodes, riskLevel, latestInsight }:
cancelAnimationFrame(animationFrameRef.current);
resizeObserver.disconnect();
simulationRef.current?.stop();
webglParticleRendererRef.current?.destroy();
webglParticleRendererRef.current = null;
particleEngineRef.current.destroy();
};
}, [draw]);

return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<canvas ref={canvasRef} style={{ width: '100%', height: '100%', display: 'block' }} />
<div style={{ position: 'relative', width: '100%', height: '100%', background: colors.void }}>
<canvas
ref={canvasRef}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block', zIndex: 1 }}
/>
<canvas
ref={particleCanvasRef}
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
display: 'block',
pointerEvents: 'none',
zIndex: 2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep WebGL particles behind foreground nodes

When WebGL is available at high/ultra, this particle canvas is stacked above the main canvas, while the main canvas still draws nodes, the risk vignette, and shadow/prediction overlays after the particle draw step. That reverses the previous Canvas2D draw order and lets particles render over node labels and foreground effects instead of between edges and nodes; use a separate foreground layer or otherwise preserve the original ordering.

Useful? React with 👍 / 👎.

}}
Comment on lines +437 to +448
Comment on lines +433 to +448

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The particle WebGL canvas is layered above the main scene canvas, so GPU particles render on top of nodes/labels regardless of draw order. That changes visual depth behavior versus the Canvas2D path (where particles are drawn before nodes) and can obscure node rendering. Keep the particle layer behind the node layer or render nodes on a higher layer to preserve ordering. [css layout issue]

Severity Level: Major ⚠️
- ❌ Agent graph nodes can be obscured by GPU particles.
- ⚠️ Reduced readability at ultra/high quality tiers.
Steps of Reproduction ✅
1. Render the main app shell via `App` in `shadow-agent/src/renderer/App.tsx:13-18`, which
uses `rendererSurfaceAdapter.GraphCanvas` as the graph view (`App.tsx:13` and
`renderer-surface-adapter.tsx:13-18`).

2. `renderer-surface-adapter.tsx:2-18` wires `GraphCanvas` to `CanvasRenderer` from
`shadow-agent/src/renderer/canvas/CanvasRenderer.tsx`, so the agent graph is drawn into
that component's return tree.

3. In `CanvasRenderer`, the main scene (background, grid, edges, nodes, labels) is drawn
into the primary 2D canvas inside the `draw` callback (`CanvasRenderer.tsx:201-247`),
while the JSX return wraps two canvases: the main canvas with `zIndex: 1` and the particle
canvas with `zIndex: 2` (`CanvasRenderer.tsx:173-189`, showing the snippet at lines
433-448).

4. When the quality profile selects WebGL particles (for `ultra`/`high` tiers with
`particleRenderMode: 'webgl'` in `quality.ts:55-90`, and `selectParticleRenderMode()` in
`quality.ts:13-21` returning `'webgl'`), `CanvasRenderer.draw` routes particles through
`webglParticleRendererRef.current.draw()` (`CanvasRenderer.tsx:249-259`), so GPU particles
are rendered on the overlay canvas with higher `zIndex` and visually sit on top of
nodes/labels, unlike the Canvas2D path where `drawParticles()` is called before
`drawAgentNode()` (`CanvasRenderer.tsx:249-247`), causing overlapping particles to obscure
node rendering.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** shadow-agent/src/renderer/canvas/CanvasRenderer.tsx
**Line:** 433:448
**Comment:**
	*Css Layout Issue: The particle WebGL canvas is layered above the main scene canvas, so GPU particles render on top of nodes/labels regardless of draw order. That changes visual depth behavior versus the Canvas2D path (where particles are drawn before nodes) and can obscure node rendering. Keep the particle layer behind the node layer or render nodes on a higher layer to preserve ordering.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

/>
<div
style={{
position: 'absolute',
Expand All @@ -385,10 +461,11 @@ export default function CanvasRenderer({ agentNodes, riskLevel, latestInsight }:
font: '600 11px/1 "Segoe UI Variable Text", system-ui, sans-serif',
letterSpacing: '0.04em',
textTransform: 'uppercase',
backdropFilter: 'blur(12px)'
backdropFilter: 'blur(12px)',
zIndex: 3
}}
>
Auto {runtimeHud.tier} • {runtimeHud.particleMode}
Auto {runtimeHud.tier} • {runtimeHud.particleMode} • {runtimeHud.particleDrawMode}
</div>
</div>
);
Expand Down
17 changes: 17 additions & 0 deletions shadow-agent/src/renderer/canvas/quality.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const QUALITY_TIER_ORDER = ['ultra', 'high', 'medium', 'low'] as const;
export type QualityTier = (typeof QUALITY_TIER_ORDER)[number];
export type QualityChangeReason = 'initial' | 'resource-budget' | 'frame-budget' | 'frame-recovery' | 'stable';
export type ParticleExecutionMode = 'worker' | 'inline' | 'disabled';
export type ParticleRenderMode = 'webgl' | 'canvas2d' | 'disabled';

export interface ResourceMetrics {
nodeCount: number;
Expand All @@ -26,6 +27,7 @@ export interface QualityProfile {
showShadowNode: boolean;
showPredictionTrail: boolean;
particleMode: ParticleExecutionMode;
particleRenderMode: ParticleRenderMode;
particlesPerEdge: number;
maxParticles: number;
particleSizeScale: number;
Expand Down Expand Up @@ -63,6 +65,7 @@ export const QUALITY_PROFILES: Record<QualityTier, QualityProfile> = {
showShadowNode: true,
showPredictionTrail: true,
particleMode: 'worker',
particleRenderMode: 'webgl',
particlesPerEdge: 10,
maxParticles: 360,
particleSizeScale: 1.15,
Expand All @@ -82,6 +85,7 @@ export const QUALITY_PROFILES: Record<QualityTier, QualityProfile> = {
showShadowNode: true,
showPredictionTrail: true,
particleMode: 'worker',
particleRenderMode: 'webgl',
particlesPerEdge: 6,
maxParticles: 220,
particleSizeScale: 1,
Expand All @@ -101,6 +105,7 @@ export const QUALITY_PROFILES: Record<QualityTier, QualityProfile> = {
showShadowNode: true,
showPredictionTrail: false,
particleMode: 'worker',
particleRenderMode: 'canvas2d',
particlesPerEdge: 3,
maxParticles: 120,
particleSizeScale: 0.86,
Expand All @@ -120,6 +125,7 @@ export const QUALITY_PROFILES: Record<QualityTier, QualityProfile> = {
showShadowNode: false,
showPredictionTrail: false,
particleMode: 'disabled',
particleRenderMode: 'disabled',
particlesPerEdge: 0,
maxParticles: 0,
particleSizeScale: 0,
Expand All @@ -133,6 +139,17 @@ export function getQualityProfile(tier: QualityTier): QualityProfile {
return QUALITY_PROFILES[tier];
}

export function selectParticleRenderMode(tier: QualityTier, webglSupported: boolean): ParticleRenderMode {
const profile = getQualityProfile(tier);
if (profile.particleMode === 'disabled' || profile.particleRenderMode === 'disabled') {
return 'disabled';
}
if (profile.particleRenderMode === 'webgl' && webglSupported) {
return 'webgl';
}
return 'canvas2d';
}

function clampPressure(value: number): number {
return Math.max(0, Math.min(100, value));
}
Expand Down
Loading
Loading