pagescene: animate selection outline with Blender-style orange pulse

Two-color sine pulse between pale orange (0xffa554) and dark orange
(0xe87d0d) over a ~3s period, driven by a uTime uniform updated each
frame from performance.now(). The motion makes the outline catch the
eye regardless of background color — a static white edge was easy to
lose against light scene geometry.

Splat bbox wireframes share the dark-orange end (no animation) so the
two selection cues read in the same visual family. Both surfaces pull
from a new selectionColors.ts module to keep the palette in sync.
This commit is contained in:
Tanish Manku committed 2026-05-14 16:35:58 -07:00
1 parent 8ed896c512
commit 8fcfc277bb
3 files changed
+57 -12

No files matched your search

@@ -38,17 +38,22 @@
// same behavior as three.js' stock OutlinePass and considered acceptable.
import * as THREE from "three";
import {
Pass,
FullScreenQuad,
} from "three/addons/postprocessing/Pass.js";
import { Pass, FullScreenQuad } from "three/addons/postprocessing/Pass.js";
import { SplatMesh } from "@sparkjsdev/spark";
import { MediaFileType } from "../enums";
import { findInternalBbox } from "./internalBbox";
import {
SELECTION_OUTLINE_COLOR,
SELECTION_OUTLINE_ACCENT_COLOR,
} from "./selectionColors";
const SELECTION_OUTLINE_COLOR = 0xffffff;
// Two-color pulse so the outline reads as motion. Pale-orange ↔ dark-
// orange in the Blender editor selection style — both ends sit in a
// warm hue that stands out against typical scene neutrals.
// Pulse period (seconds). Angular freq baked into the uniform.
const SELECTION_PULSE_PERIOD_SEC = 3.0;
// Sample radius for the silhouette edge detect, in screen pixels.
const OUTLINE_THICKNESS = 1.5;
const OUTLINE_THICKNESS = 1;
// Depth value at the far plane after clear. Anything below means
// "selected geometry rasterized here."
const FAR_DEPTH_THRESHOLD = 0.999999;
@@ -207,9 +212,11 @@ export class SelectionOutlinePass extends Pass {
// Step 3: silhouette overlay quad. Reads selectedTarget.depthTexture,
// paints outline color where the 3x3 neighborhood straddles selected/
// not-selected, discards otherwise. autoClear=false preserves
// writeBuffer from step 1.
// writeBuffer from step 1. `uTime` (seconds) drives the color pulse
// inside the fragment shader.
this.outlineMaterial.uniforms["selectedDepth"].value =
this.selectedTarget.depthTexture;
this.outlineMaterial.uniforms["uTime"].value = performance.now() * 0.001;
const prevAutoClear = renderer.autoClear;
renderer.autoClear = false;
renderer.setRenderTarget(writeBuffer);
@@ -251,6 +258,17 @@ export class SelectionOutlinePass extends Pass {
outlineColor: {
value: new THREE.Color(SELECTION_OUTLINE_COLOR),
},
outlineAccentColor: {
value: new THREE.Color(SELECTION_OUTLINE_ACCENT_COLOR),
},
// Angular frequency for the pulse: 2π / period.
// Multiply by uTime (seconds) inside the shader.
pulseAngularFreq: {
value: (2 * Math.PI) / SELECTION_PULSE_PERIOD_SEC,
},
// Seconds since some arbitrary origin. Updated each render()
// from `performance.now() * 0.001`.
uTime: { value: 0 },
outlineThickness: { value: OUTLINE_THICKNESS },
},
vertexShader: /* glsl */ `
@@ -264,13 +282,22 @@ export class SelectionOutlinePass extends Pass {
// selectedDepth.x < ${FAR_DEPTH_THRESHOLD} encodes "selected
// geometry rasterized here." Sample a 3x3 neighborhood; if any
// neighbor's selected-presence differs from the center's, the
// fragment is on the silhouette. Paint outlineColor there;
// discard otherwise so the quad blends onto writeBuffer
// without overwriting non-silhouette pixels.
// fragment is on the silhouette. Non-silhouette fragments
// discard so the quad blends onto writeBuffer without
// overwriting non-silhouette pixels.
//
// Silhouette pixels mix between outlineColor and
// outlineAccentColor via a 0..1 sine driven by uTime. Two
// colors (rather than a single intensity pulse) so the outline
// remains visible against backgrounds of either tone — a pure
// white pulse is easy to lose against white scene edges.
fragmentShader: /* glsl */ `
uniform sampler2D selectedDepth;
uniform vec4 screenSize;
uniform vec3 outlineColor;
uniform vec3 outlineAccentColor;
uniform float pulseAngularFreq;
uniform float uTime;
uniform float outlineThickness;
varying vec2 vUv;
@@ -295,7 +322,9 @@ export class SelectionOutlinePass extends Pass {
}
if (!edge) discard;
gl_FragColor = vec4(outlineColor, 1.0);
float pulse = sin(uTime * pulseAngularFreq) * 0.5 + 0.5;
vec3 col = mix(outlineColor, outlineAccentColor, pulse);
gl_FragColor = vec4(col, 1.0);
}
`,
});
@@ -11,6 +11,7 @@
// don't write the literal string inline anywhere.
import * as THREE from "three";
import { SELECTION_OUTLINE_ACCENT_COLOR } from "./selectionColors";
export const BBOX_INTERNAL_KEY = "__bbox_internal";
@@ -65,7 +66,10 @@ function createInternalBbox(localBox: THREE.Box3): THREE.LineSegments {
boxGeo.dispose();
const lines = new THREE.LineSegments(
edges,
new THREE.LineBasicMaterial({ color: 0xffffff }),
// Static dark orange — matches the outline pulse's trough color so
// the splat selection cue reads in the same visual family as the
// mesh-selection halo. No animation on the bbox itself.
new THREE.LineBasicMaterial({ color: SELECTION_OUTLINE_ACCENT_COLOR }),
);
lines.position.copy(center);
lines.userData[BBOX_INTERNAL_KEY] = true;
@@ -0,0 +1,12 @@
// Brand selection palette for the editor's 3D scene. Both the outline
// pulse in SelectionOutlinePass and the splat bbox wireframe in
// internalBbox source their colors from here so the visual language
// stays consistent across the two cues. Tweak one constant and both
// surfaces follow.
// Pale orange — the bright end of the outline pulse.
export const SELECTION_OUTLINE_COLOR = 0xffa554;
// Dark orange — the dim end of the outline pulse AND the static
// color used for the splat bbox wireframe (no animation there).
export const SELECTION_OUTLINE_ACCENT_COLOR = 0xe87d0d;