Skip to content

Commit 3efbeba

Browse files
committed
trail texture
1 parent cefc7c6 commit 3efbeba

File tree

4 files changed

+256
-0
lines changed

4 files changed

+256
-0
lines changed

libs/soba/misc/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ export * from './html/html';
1010
export * from './sampler/sampler';
1111
export * from './shadow/shadow';
1212
export * from './stats-gl/stats-gl';
13+
export * from './trail-texture/inject-trail-texture';
1314
export * from './trail/trail';
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Injector, computed, runInInjectionContext } from '@angular/core';
2+
import { NgtThreeEvent, assertInjectionContext, injectBeforeRender } from 'angular-three';
3+
import { TrailTexture, type TrailConfig } from './trail-texture';
4+
5+
export function injectNgtsTrailTexture(
6+
trailConfigFn: () => Partial<TrailConfig>,
7+
{ injector }: { injector?: Injector } = {},
8+
) {
9+
injector = assertInjectionContext(injectNgtsTrailTexture, injector);
10+
return runInInjectionContext(injector, () => {
11+
const config = computed(() => trailConfigFn() || {});
12+
const trail = computed(() => new TrailTexture(config()));
13+
const texture = computed(() => trail().texture);
14+
15+
injectBeforeRender(({ delta }) => {
16+
trail().update(delta);
17+
});
18+
19+
const onMove = (ev: NgtThreeEvent<PointerEvent>) => trail().addTouch(ev.uv!);
20+
21+
return { texture, onMove };
22+
});
23+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { Texture } from 'three';
2+
3+
export type Point = {
4+
x: number;
5+
y: number;
6+
age: number;
7+
force: number;
8+
};
9+
10+
export type TrailConfig = {
11+
/** texture size (default: 256x256) */
12+
size?: number;
13+
/** Max age (ms) of trail points (default: 750) */
14+
maxAge?: number;
15+
/** Trail radius (default: 0.3) */
16+
radius?: number;
17+
/** Canvas trail opacity (default: 0.2) */
18+
intensity?: number;
19+
/** Add points in between slow pointer events (default: 0) */
20+
interpolate?: number;
21+
/** Moving average of pointer force (default: 0) */
22+
smoothing?: number;
23+
/** Minimum pointer force (default: 0.3) */
24+
minForce?: number;
25+
/** Blend mode (default: 'screen') */
26+
blend?: CanvasRenderingContext2D['globalCompositeOperation'];
27+
/** Easing (default: easeCircOut) */
28+
ease?: (t: number) => number;
29+
};
30+
31+
// default ease
32+
const easeCircleOut = (x: number) => Math.sqrt(1 - Math.pow(x - 1, 2));
33+
34+
// smooth new sample (measurement) based on previous sample (current)
35+
function smoothAverage(current: number, measurement: number, smoothing = 0.9) {
36+
return measurement * smoothing + current * (1.0 - smoothing);
37+
}
38+
39+
export class TrailTexture {
40+
trail: Point[];
41+
canvas!: HTMLCanvasElement;
42+
ctx!: CanvasRenderingContext2D;
43+
texture!: Texture;
44+
force: number;
45+
size: number;
46+
maxAge: number;
47+
radius: number;
48+
intensity: number;
49+
ease: (t: number) => number;
50+
minForce: number;
51+
interpolate: number;
52+
smoothing: number;
53+
blend: CanvasRenderingContext2D['globalCompositeOperation'];
54+
55+
constructor({
56+
size = 256,
57+
maxAge = 750,
58+
radius = 0.3,
59+
intensity = 0.2,
60+
interpolate = 0,
61+
smoothing = 0,
62+
minForce = 0.3,
63+
blend = 'screen', // source-over is canvas default. Others are slower
64+
ease = easeCircleOut,
65+
}: TrailConfig = {}) {
66+
this.size = size;
67+
this.maxAge = maxAge;
68+
this.radius = radius;
69+
this.intensity = intensity;
70+
this.ease = ease;
71+
this.interpolate = interpolate;
72+
this.smoothing = smoothing;
73+
this.minForce = minForce;
74+
this.blend = blend as GlobalCompositeOperation;
75+
76+
this.trail = [];
77+
this.force = 0;
78+
this.initTexture();
79+
}
80+
81+
initTexture() {
82+
this.canvas = document.createElement('canvas');
83+
this.canvas.width = this.canvas.height = this.size;
84+
this.ctx = this.canvas.getContext('2d')!;
85+
this.ctx.fillStyle = 'black';
86+
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
87+
88+
this.texture = new Texture(this.canvas);
89+
90+
this.canvas.id = 'touchTexture';
91+
this.canvas.style.width = this.canvas.style.height = `${this.canvas.width}px`;
92+
}
93+
94+
update(delta: number) {
95+
this.clear();
96+
97+
// age points
98+
this.trail.forEach((point, i) => {
99+
point.age += delta * 1000;
100+
// remove old
101+
if (point.age > this.maxAge) {
102+
this.trail.splice(i, 1);
103+
}
104+
});
105+
106+
// reset force when empty (when smoothing)
107+
if (!this.trail.length) this.force = 0;
108+
109+
this.trail.forEach((point) => {
110+
this.drawTouch(point);
111+
});
112+
113+
this.texture.needsUpdate = true;
114+
}
115+
116+
clear() {
117+
this.ctx.globalCompositeOperation = 'source-over';
118+
this.ctx.fillStyle = 'black';
119+
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
120+
}
121+
122+
addTouch(point: Pick<Point, 'x' | 'y'>) {
123+
const last = this.trail[this.trail.length - 1];
124+
125+
if (last) {
126+
const dx = last.x - point.x;
127+
const dy = last.y - point.y;
128+
const dd = dx * dx + dy * dy;
129+
130+
const force = Math.max(this.minForce, Math.min(dd * 10000, 1));
131+
132+
this.force = smoothAverage(force, this.force, this.smoothing);
133+
134+
if (!!this.interpolate) {
135+
const lines = Math.ceil(dd / Math.pow((this.radius * 0.5) / this.interpolate, 2));
136+
137+
if (lines > 1) {
138+
for (let i = 1; i < lines; i++) {
139+
this.trail.push({
140+
x: last.x - (dx / lines) * i,
141+
y: last.y - (dy / lines) * i,
142+
age: 0,
143+
force,
144+
});
145+
}
146+
}
147+
}
148+
}
149+
this.trail.push({ x: point.x, y: point.y, age: 0, force: this.force });
150+
}
151+
152+
drawTouch(point: Point) {
153+
const pos = {
154+
x: point.x * this.size,
155+
y: (1 - point.y) * this.size,
156+
};
157+
158+
let intensity = 1;
159+
if (point.age < this.maxAge * 0.3) {
160+
intensity = this.ease(point.age / (this.maxAge * 0.3));
161+
} else {
162+
intensity = this.ease(1 - (point.age - this.maxAge * 0.3) / (this.maxAge * 0.7));
163+
}
164+
165+
intensity *= point.force;
166+
167+
// apply blending
168+
this.ctx.globalCompositeOperation = this.blend;
169+
170+
const radius = this.size * this.radius * intensity;
171+
const grd = this.ctx.createRadialGradient(
172+
pos.x,
173+
pos.y,
174+
Math.max(0, radius * 0.25),
175+
pos.x,
176+
pos.y,
177+
Math.max(0, radius),
178+
);
179+
grd.addColorStop(0, `rgba(255, 255, 255, ${this.intensity})`);
180+
grd.addColorStop(1, `rgba(0, 0, 0, 0.0)`);
181+
182+
this.ctx.beginPath();
183+
this.ctx.fillStyle = grd;
184+
this.ctx.arc(pos.x, pos.y, Math.max(0, radius), 0, Math.PI * 2);
185+
this.ctx.fill();
186+
}
187+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { CUSTOM_ELEMENTS_SCHEMA, Component, Input } from '@angular/core';
2+
import { signalStore } from 'angular-three';
3+
import { injectNgtsTrailTexture } from 'angular-three-soba/misc';
4+
import { makeDecorators, makeStoryObject, number } from '../setup-canvas';
5+
6+
@Component({
7+
standalone: true,
8+
template: `
9+
<ngt-mesh [scale]="7" (pointermove)="trailTexture.onMove($event)">
10+
<ngt-plane-geometry />
11+
<ngt-mesh-basic-material [map]="trailTexture.texture()" />
12+
</ngt-mesh>
13+
`,
14+
schemas: [CUSTOM_ELEMENTS_SCHEMA],
15+
})
16+
class DefaultTrailTextureStory {
17+
private inputs = signalStore({ size: 256, radius: 0.3, maxAge: 750 });
18+
19+
trailTexture = injectNgtsTrailTexture(this.inputs.state);
20+
21+
@Input() set size(size: number) {
22+
this.inputs.set({ size });
23+
}
24+
25+
@Input() set radius(radius: number) {
26+
this.inputs.set({ radius });
27+
}
28+
29+
@Input() set maxAge(maxAge: number) {
30+
this.inputs.set({ maxAge });
31+
}
32+
}
33+
34+
export default {
35+
title: 'Misc/injectNgtsTrailTexture',
36+
decorators: makeDecorators(),
37+
};
38+
39+
export const Default = makeStoryObject(DefaultTrailTextureStory, {
40+
argsOptions: {
41+
size: number(256, { min: 64, step: 8 }),
42+
radius: number(0.3, { range: true, min: 0.1, max: 1, step: 0.1 }),
43+
maxAge: number(750, { range: true, min: 300, max: 1000, step: 100 }),
44+
},
45+
});

0 commit comments

Comments
 (0)