@5even7/dlc-ui 0.2.11 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/capsule.mjs CHANGED
@@ -1,117 +1,123 @@
1
- /**
2
- * Tiny event emitter. Accepts any event name so the API stays open for
3
- * future events (hover, click, custom) without breaking changes.
4
- */
5
- function createEmitter() {
6
- // Plain object storage (no Map/Set) so the legacy build has no API
7
- // dependencies beyond what IE11 provides.
8
- const listeners = Object.create(null);
9
-
10
- return {
11
- on(event, fn) {
12
- if (!listeners[event]) listeners[event] = [];
13
- listeners[event].push(fn);
14
- return () => {
15
- const current = listeners[event];
16
- if (current) {
17
- const index = current.indexOf(fn);
18
- if (index !== -1) current.splice(index, 1);
19
- }
20
- };
21
- },
22
- off(event, fn) {
23
- const current = listeners[event];
24
- if (!current) return false;
25
- const index = current.indexOf(fn);
26
- if (index === -1) return false;
27
- current.splice(index, 1);
28
- return true;
29
- },
30
- emit(event, ...args) {
31
- const current = listeners[event];
32
- if (!current) return;
33
- for (const fn of current.slice()) fn(...args);
34
- }
35
- };
1
+ /**
2
+ * Tiny event emitter. Accepts any event name so the API stays open for
3
+ * future events (hover, click, custom) without breaking changes.
4
+ */
5
+ function createEmitter() {
6
+ // Plain object storage (no Map/Set) so the legacy build has no API
7
+ // dependencies beyond what IE11 provides.
8
+ const listeners = Object.create(null);
9
+
10
+ return {
11
+ on(event, fn) {
12
+ if (!listeners[event]) listeners[event] = [];
13
+ listeners[event].push(fn);
14
+ return () => {
15
+ const current = listeners[event];
16
+ if (current) {
17
+ const index = current.indexOf(fn);
18
+ if (index !== -1) current.splice(index, 1);
19
+ }
20
+ };
21
+ },
22
+ off(event, fn) {
23
+ const current = listeners[event];
24
+ if (!current) return false;
25
+ const index = current.indexOf(fn);
26
+ if (index === -1) return false;
27
+ current.splice(index, 1);
28
+ return true;
29
+ },
30
+ emit(event, ...args) {
31
+ const current = listeners[event];
32
+ if (!current) return;
33
+ for (const fn of current.slice()) fn(...args);
34
+ }
35
+ };
36
+ }
37
+
38
+ const UNIT_PATTERN = /^[\d.]+(?:px|%|vw|vh|vmin|vmax|em|rem)$/;
39
+
40
+ /**
41
+ * Normalize a size option to a CSS length string.
42
+ * Accepts numbers (treated as px) or strings with px/%, vw/vh/vmin/vmax, em/rem.
43
+ */
44
+ function parseSize(value) {
45
+ if (typeof value === 'number') {
46
+ if (!Number.isFinite(value) || value < 0) throw new Error(`Invalid size: ${value}`);
47
+ return `${value}px`;
48
+ }
49
+ if (typeof value !== 'string') throw new Error(`Invalid size: ${String(value)}`);
50
+ const trimmed = value.trim();
51
+ if (!trimmed) throw new Error('Invalid size: empty string');
52
+ if (/^\d+(?:\.\d+)?$/.test(trimmed)) return `${trimmed}px`;
53
+ if (!UNIT_PATTERN.test(trimmed)) {
54
+ throw new Error(`Invalid size or unsupported unit: "${value}" (use px, %, vw, vh, vmin, vmax, em, rem)`);
55
+ }
56
+ return trimmed;
36
57
  }
37
58
 
38
- const UNIT_PATTERN = /^[\d.]+(?:px|%|vw|vh|vmin|vmax|em|rem)$/;
39
-
40
- /**
41
- * Normalize a size option to a CSS length string.
42
- * Accepts numbers (treated as px) or strings with px/%, vw/vh/vmin/vmax, em/rem.
43
- */
44
- function parseSize(value) {
45
- if (typeof value === 'number') {
46
- if (!Number.isFinite(value) || value < 0) throw new Error(`Invalid size: ${value}`);
47
- return `${value}px`;
48
- }
49
- if (typeof value !== 'string') throw new Error(`Invalid size: ${String(value)}`);
50
- const trimmed = value.trim();
51
- if (!trimmed) throw new Error('Invalid size: empty string');
52
- if (/^\d+(?:\.\d+)?$/.test(trimmed)) return `${trimmed}px`;
53
- if (!UNIT_PATTERN.test(trimmed)) {
54
- throw new Error(`Invalid size or unsupported unit: "${value}" (use px, %, vw, vh, vmin, vmax, em, rem)`);
55
- }
56
- return trimmed;
59
+ const QUALITY_TIERS = {
60
+ low: { dpr: 1 },
61
+ medium: { dpr: 1.5 },
62
+ high: { dpr: 2 }
63
+ };
64
+
65
+ function autoDprCap() {
66
+ const nav = typeof navigator !== 'undefined' ? navigator : null;
67
+ const memory = nav && typeof nav.deviceMemory === 'number' ? nav.deviceMemory : 8;
68
+ const cores = nav && typeof nav.hardwareConcurrency === 'number' ? nav.hardwareConcurrency : 8;
69
+ return memory <= 4 || cores <= 4 ? 1.5 : 2;
57
70
  }
58
71
 
59
- const QUALITY_TIERS = {
60
- low: { dpr: 1 },
61
- medium: { dpr: 1.5 },
62
- high: { dpr: 2 }
63
- };
64
-
65
- function autoDprCap() {
66
- const nav = typeof navigator !== 'undefined' ? navigator : null;
67
- const memory = nav && typeof nav.deviceMemory === 'number' ? nav.deviceMemory : 8;
68
- const cores = nav && typeof nav.hardwareConcurrency === 'number' ? nav.hardwareConcurrency : 8;
69
- return memory <= 4 || cores <= 4 ? 1.5 : 2;
70
- }
71
-
72
- function dprCapFor(quality) {
73
- if (quality === 'auto') return autoDprCap();
74
- const tier = QUALITY_TIERS[quality] || QUALITY_TIERS.medium;
75
- return tier.dpr;
72
+ function dprCapFor(quality) {
73
+ if (quality === 'auto') return autoDprCap();
74
+ const tier = QUALITY_TIERS[quality] || QUALITY_TIERS.medium;
75
+ return tier.dpr;
76
76
  }
77
77
 
78
- /**
79
- * Merge defaults < preset < user. Unknown user keys are preserved so the
80
- * component API can grow (new props, cssVars, callbacks) without a breaking
81
- * change.
82
- */
83
- function normalizeOptions(defaults, preset, user) {
84
- // undefined means "not provided" (e.g. Vue $props with unset props):
85
- // drop those keys before merging so defaults/preset values survive.
86
- const omitUndefined = (source) => {
87
- const result = {};
88
- for (const key of Object.keys(source)) {
89
- if (source[key] !== undefined) result[key] = source[key];
90
- }
91
- return result;
92
- };
93
- const presetOptions = omitUndefined(preset && typeof preset === 'object' ? preset : {});
94
- const userOptions = omitUndefined(user && typeof user === 'object' ? user : {});
95
- return { ...defaults, ...presetOptions, ...userOptions };
78
+ function effectiveDprCap(quality, renderScale = 1) {
79
+ const scale = Number(renderScale);
80
+ const normalizedScale = Number.isFinite(scale) ? Math.min(1, Math.max(0.25, scale)) : 1;
81
+ return Math.max(0.5, dprCapFor(quality) * normalizedScale);
96
82
  }
97
83
 
98
- /**
99
- * 公共色板:NC-01~NC-06 的四色组(底色 / 主色 / 辅色 / 高光色)。
100
- * Capsule 预置与 dlc-color 等组件共用,新增色板只改这里。
101
- */
102
- const PALETTES = {
103
- original: ['#FFF3EA', '#F5B27A', '#F67BC6', '#A978E8'],
104
- ocean: ['#EAF6FF', '#8FD0FF', '#3B87F6', '#6B58E9'],
105
- klein: ['#EDF2FF', '#2F58D5', '#1B2040', '#E07A43'],
106
- ultraviolet: ['#F2EEFF', '#B99AF1', '#8F74DB', '#D7D85C'],
107
- chrome: ['#F5F6F8', '#B9C0CC', '#7F8793', '#4A4F59'],
108
- plus: ['#FFF0E6', '#F6C26B', '#F98A64', '#E86D74']
84
+ /**
85
+ * Merge defaults < preset < user. Unknown user keys are preserved so the
86
+ * component API can grow (new props, cssVars, callbacks) without a breaking
87
+ * change.
88
+ */
89
+ function normalizeOptions(defaults, preset, user) {
90
+ // undefined means "not provided" (e.g. Vue $props with unset props):
91
+ // drop those keys before merging so defaults/preset values survive.
92
+ const omitUndefined = (source) => {
93
+ const result = {};
94
+ for (const key of Object.keys(source)) {
95
+ if (source[key] !== undefined) result[key] = source[key];
96
+ }
97
+ return result;
98
+ };
99
+ const presetOptions = omitUndefined(preset && typeof preset === 'object' ? preset : {});
100
+ const userOptions = omitUndefined(user && typeof user === 'object' ? user : {});
101
+ return { ...defaults, ...presetOptions, ...userOptions };
102
+ }
103
+
104
+ /**
105
+ * 公共色板:NC-01~NC-06 的四色组(底色 / 主色 / 辅色 / 高光色)。
106
+ * Capsule 预置与 dlc-color 等组件共用,新增色板只改这里。
107
+ */
108
+ const PALETTES = {
109
+ original: ['#FFF3EA', '#F5B27A', '#F67BC6', '#A978E8'],
110
+ ocean: ['#EAF6FF', '#8FD0FF', '#3B87F6', '#6B58E9'],
111
+ klein: ['#EDF2FF', '#2F58D5', '#1B2040', '#E07A43'],
112
+ ultraviolet: ['#F2EEFF', '#B99AF1', '#8F74DB', '#D7D85C'],
113
+ chrome: ['#F5F6F8', '#B9C0CC', '#7F8793', '#4A4F59'],
114
+ plus: ['#FFF0E6', '#F6C26B', '#F98A64', '#E86D74']
109
115
  };
110
116
 
111
- /**
112
- * Capsule 预置:仅星云材质 NC-01~NC-06(NC-07~09 aurora 已移除)。
113
- * 颜色引用公共色板,seed/speed 决定形态与流速。
114
- */
117
+ /**
118
+ * Capsule 预置:仅星云材质 NC-01~NC-06(NC-07~09 aurora 已移除)。
119
+ * 颜色引用公共色板,seed/speed 决定形态与流速。
120
+ */
115
121
  const CAPSULE_PRESETS = [
116
122
  { id: 'original', code: 'NC-01', name: '初光', group: 'warm', seed: 1.7, speed: 0.5, colors: [...PALETTES.original] },
117
123
  { id: 'ocean', code: 'NC-02', name: '沧溟', group: 'cold', seed: 8.2, speed: 0.48, colors: [...PALETTES.ocean] },
@@ -147,6 +153,11 @@ const DEFAULTS = {
147
153
  height: 160,
148
154
  quality: 'auto',
149
155
  renderer: 'auto',
156
+ renderScale: 1,
157
+ powerPreference: 'high-performance',
158
+ fps: 60,
159
+ paused: false,
160
+ static: false,
150
161
  respectReducedMotion: true,
151
162
  mouseColor: true,
152
163
  textRatio: 39
@@ -158,7 +169,7 @@ const DEFAULTS = {
158
169
  * Templates use {brand} {code} {name} placeholders.
159
170
  */
160
171
  const COPY = {
161
- capsuleAria: '打开 {name} 沉浸预览'
172
+ capsuleAria: '打开 {name} 沉浸预览'
162
173
  };
163
174
 
164
175
  /**
@@ -377,231 +388,254 @@ function hexToRgb01(color) {
377
388
  ];
378
389
  }
379
390
 
380
- const VERTEX_SHADER = `#version 300 es
381
- in vec2 a_position;
382
- out vec2 v_uv;
383
- void main() {
384
- v_uv = a_position * 0.5 + 0.5;
385
- gl_Position = vec4(a_position, 0.0, 1.0);
386
- }`;
387
-
388
- const FRAGMENT_SHADER = `#version 300 es
389
- precision highp float;
390
-
391
- in vec2 v_uv;
392
- out vec4 outColor;
393
-
394
- uniform vec2 u_resolution;
395
- uniform float u_time;
396
- uniform float u_seed;
397
- uniform float u_motion;
398
- uniform vec2 u_pointer;
399
- uniform vec3 u_colorA;
400
- uniform vec3 u_colorB;
401
- uniform vec3 u_colorC;
402
- uniform vec3 u_colorD;
403
-
404
- float hash21(vec2 p) {
405
- p = fract(p * vec2(123.34, 456.21));
406
- p += dot(p, p + 45.32 + u_seed);
407
- return fract(p.x * p.y);
408
- }
409
-
410
- float noise(vec2 p) {
411
- vec2 i = floor(p);
412
- vec2 f = fract(p);
413
- f = f * f * (3.0 - 2.0 * f);
414
- float a = hash21(i);
415
- float b = hash21(i + vec2(1.0, 0.0));
416
- float c = hash21(i + vec2(0.0, 1.0));
417
- float d = hash21(i + vec2(1.0, 1.0));
418
- return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
419
- }
420
-
421
- float fbm(vec2 p) {
422
- float value = 0.0;
423
- float amplitude = 0.52;
424
- mat2 rotation = mat2(0.80, 0.60, -0.60, 0.80);
425
- for (int i = 0; i < 6; i++) {
426
- value += amplitude * noise(p);
427
- p = rotation * p * 2.03 + 17.7;
428
- amplitude *= 0.5;
429
- }
430
- return value;
431
- }
432
-
433
- float gaussian(float value, float center, float width) {
434
- return exp(-pow(value - center, 2.0) / max(width, 0.0001));
435
- }
436
-
437
- vec3 palette(float t) {
438
- t = clamp(t, 0.0, 1.0);
439
- vec3 shadow = mix(u_colorA, u_colorB, smoothstep(0.06, 0.62, t));
440
- vec3 body = mix(u_colorB, u_colorC, smoothstep(0.30, 0.82, t));
441
- vec3 highlight = mix(u_colorC, u_colorD, smoothstep(0.74, 1.0, t));
442
- vec3 restrained = mix(shadow, body, smoothstep(0.26, 0.72, t));
443
- return mix(restrained, highlight, smoothstep(0.78, 0.97, t));
444
- }
445
-
446
- vec3 renderNebula(vec2 uv, vec2 p, vec2 pointer, float distanceToPointer, float t) {
447
- vec2 delta = p - pointer;
448
- float influence = exp(-distanceToPointer * 4.6) * u_motion;
449
- float angle = influence * 1.7;
450
- mat2 swirl = mat2(cos(angle), -sin(angle), sin(angle), cos(angle));
451
- p = pointer + swirl * delta;
452
- p += normalize(delta + 0.0001) * influence * 0.08;
453
-
454
- vec2 drift = vec2(t * 0.22, -t * 0.13);
455
- vec2 q = vec2(
456
- fbm(p * 1.35 + drift + u_seed),
457
- fbm(p * 1.35 + vec2(5.2, 1.3) - drift * 0.85)
458
- );
459
- vec2 r = vec2(
460
- fbm(p * 2.0 + 3.6 * q + vec2(1.7, 9.2) + t * 0.10),
461
- fbm(p * 2.0 + 3.0 * q + vec2(8.3, 2.8) - t * 0.085)
462
- );
463
-
464
- float cloud = fbm(p * 1.7 + 4.2 * r);
465
- float veins = fbm(p * 4.0 - 2.0 * q + t * 0.065);
466
- float nebula = smoothstep(0.18, 0.91, cloud * 0.9 + veins * 0.22);
467
-
468
- vec3 color = palette(nebula);
469
- color += u_colorD * pow(max(cloud - 0.63, 0.0), 2.0) * 1.05;
470
- color *= 0.78 + 0.34 * smoothstep(0.15, 0.9, veins);
471
-
472
- vec2 starGrid = floor((uv + vec2(u_seed * 0.013, 0.0)) * vec2(132.0, 58.0));
473
- vec2 starCell = fract(uv * vec2(132.0, 58.0)) - 0.5;
474
- float starRandom = hash21(starGrid);
475
- float starShape = smoothstep(0.075, 0.0, length(starCell));
476
- float starMask = step(0.989, starRandom) * starShape;
477
- float twinkle = 0.35 + 0.65 * sin(t * (1.0 + starRandom * 2.4) + starRandom * 40.0) * 0.5 + 0.5;
478
- color += starMask * twinkle * mix(u_colorC, u_colorD, starRandom) * 1.05;
479
-
480
- float pointerGlow = exp(-distanceToPointer * 7.0) * u_motion;
481
- color += u_colorD * pointerGlow * 0.28;
482
- return color;
483
- }
484
-
485
- void main() {
486
- vec2 uv = v_uv;
487
- vec2 p = uv - 0.5;
488
- p.x *= u_resolution.x / max(u_resolution.y, 1.0);
489
-
490
- vec2 pointer = u_pointer - 0.5;
491
- pointer.x *= u_resolution.x / max(u_resolution.y, 1.0);
492
- float distanceToPointer = length(p - pointer);
493
-
494
- vec3 color = renderNebula(uv, p, pointer, distanceToPointer, u_time);
495
- float vignette = smoothstep(0.94, 0.18, length((uv - 0.5) * vec2(1.0, 1.35)));
496
- color *= 0.70 + vignette * 0.42;
497
- color = pow(max(color, vec3(0.0)), vec3(0.88));
498
-
499
- outColor = vec4(color, 1.0);
500
- }`;
501
-
502
- function compileShader(gl, type, source) {
503
- const shader = gl.createShader(type);
504
- gl.shaderSource(shader, source);
505
- gl.compileShader(shader);
506
- if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
507
- const message = gl.getShaderInfoLog(shader) || 'Unknown shader compile error';
508
- gl.deleteShader(shader);
509
- throw new Error(message);
510
- }
511
- return shader;
512
- }
513
-
514
- function createProgram(gl) {
515
- const vertex = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
516
- const fragment = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
517
- const program = gl.createProgram();
518
- gl.attachShader(program, vertex);
519
- gl.attachShader(program, fragment);
520
- gl.linkProgram(program);
521
- gl.deleteShader(vertex);
522
- gl.deleteShader(fragment);
523
- if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
524
- const message = gl.getProgramInfoLog(program) || 'Unknown program link error';
525
- gl.deleteProgram(program);
526
- throw new Error(message);
527
- }
528
- return program;
529
- }
530
-
531
- class CosmicRenderer {
532
- constructor(canvas, preset, options = {}) {
533
- this.canvas = canvas;
534
- this.preset = { ...preset };
535
- this.options = { dprCap: 1.75, mouseColor: true, ...options };
536
- this.gl = canvas.getContext('webgl2', {
537
- alpha: false,
538
- antialias: false,
539
- depth: false,
540
- powerPreference: 'high-performance',
541
- preserveDrawingBuffer: false
542
- });
543
- if (!this.gl) throw new Error('WebGL2 is not available');
544
-
545
- this.program = createProgram(this.gl);
546
- this.locations = this.#getLocations();
547
- this.pointer = [0.72, 0.45];
548
- this.pointerTarget = [...this.pointer];
549
- this.motion = 0;
550
- this.motionTarget = 0;
551
- this.timeOffset = preset.seed * 0.73;
552
- this.visible = true;
553
- this.disposed = false;
554
-
555
- this.#setupGeometry();
556
- this.#bindEvents();
557
- this.resize();
558
- }
559
-
560
- #getLocations() {
561
- const gl = this.gl;
562
- const uniform = (name) => gl.getUniformLocation(this.program, name);
563
- return {
564
- position: gl.getAttribLocation(this.program, 'a_position'),
565
- resolution: uniform('u_resolution'),
566
- time: uniform('u_time'),
567
- seed: uniform('u_seed'),
568
- motion: uniform('u_motion'),
569
- pointer: uniform('u_pointer'),
570
- colorA: uniform('u_colorA'),
571
- colorB: uniform('u_colorB'),
572
- colorC: uniform('u_colorC'),
573
- colorD: uniform('u_colorD')
574
- };
575
- }
576
-
577
- #setupGeometry() {
578
- const gl = this.gl;
579
- const vertices = new Float32Array([-1, -1, 3, -1, -1, 3]);
580
- this.buffer = gl.createBuffer();
581
- gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
582
- gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
583
- }
584
-
585
- #bindEvents() {
586
- if (this.options.mouseColor === false) return;
587
- this.eventTarget = this.options.eventTarget || this.canvas.parentElement || this.canvas;
588
- this.onPointerMove = (event) => {
589
- const rect = this.canvas.getBoundingClientRect();
590
- this.pointerTarget[0] = (event.clientX - rect.left) / Math.max(rect.width, 1);
591
- this.pointerTarget[1] = 1 - (event.clientY - rect.top) / Math.max(rect.height, 1);
592
- this.motionTarget = 1;
593
- };
594
- this.onPointerLeave = () => { this.motionTarget = 0; };
595
- this.eventTarget.addEventListener('pointermove', this.onPointerMove, { passive: true });
596
- this.eventTarget.addEventListener('pointerdown', this.onPointerMove, { passive: true });
597
- this.eventTarget.addEventListener('pointerleave', this.onPointerLeave, { passive: true });
598
- }
599
-
600
- setPreset(preset) {
601
- this.preset = { ...preset };
602
- this.timeOffset = preset.seed * 0.73;
603
- }
604
-
391
+ const VERTEX_SHADER = `#version 300 es
392
+ in vec2 a_position;
393
+ out vec2 v_uv;
394
+ void main() {
395
+ v_uv = a_position * 0.5 + 0.5;
396
+ gl_Position = vec4(a_position, 0.0, 1.0);
397
+ }`;
398
+
399
+ const FRAGMENT_SHADER = `#version 300 es
400
+ precision highp float;
401
+
402
+ in vec2 v_uv;
403
+ out vec4 outColor;
404
+
405
+ uniform vec2 u_resolution;
406
+ uniform float u_time;
407
+ uniform float u_seed;
408
+ uniform float u_motion;
409
+ uniform vec2 u_pointer;
410
+ uniform vec3 u_colorA;
411
+ uniform vec3 u_colorB;
412
+ uniform vec3 u_colorC;
413
+ uniform vec3 u_colorD;
414
+
415
+ float hash21(vec2 p) {
416
+ p = fract(p * vec2(123.34, 456.21));
417
+ p += dot(p, p + 45.32 + u_seed);
418
+ return fract(p.x * p.y);
419
+ }
420
+
421
+ float noise(vec2 p) {
422
+ vec2 i = floor(p);
423
+ vec2 f = fract(p);
424
+ f = f * f * (3.0 - 2.0 * f);
425
+ float a = hash21(i);
426
+ float b = hash21(i + vec2(1.0, 0.0));
427
+ float c = hash21(i + vec2(0.0, 1.0));
428
+ float d = hash21(i + vec2(1.0, 1.0));
429
+ return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
430
+ }
431
+
432
+ float fbm(vec2 p) {
433
+ float value = 0.0;
434
+ float amplitude = 0.52;
435
+ mat2 rotation = mat2(0.80, 0.60, -0.60, 0.80);
436
+ for (int i = 0; i < 6; i++) {
437
+ value += amplitude * noise(p);
438
+ p = rotation * p * 2.03 + 17.7;
439
+ amplitude *= 0.5;
440
+ }
441
+ return value;
442
+ }
443
+
444
+ float gaussian(float value, float center, float width) {
445
+ return exp(-pow(value - center, 2.0) / max(width, 0.0001));
446
+ }
447
+
448
+ vec3 palette(float t) {
449
+ t = clamp(t, 0.0, 1.0);
450
+ vec3 shadow = mix(u_colorA, u_colorB, smoothstep(0.06, 0.62, t));
451
+ vec3 body = mix(u_colorB, u_colorC, smoothstep(0.30, 0.82, t));
452
+ vec3 highlight = mix(u_colorC, u_colorD, smoothstep(0.74, 1.0, t));
453
+ vec3 restrained = mix(shadow, body, smoothstep(0.26, 0.72, t));
454
+ return mix(restrained, highlight, smoothstep(0.78, 0.97, t));
455
+ }
456
+
457
+ vec3 renderNebula(vec2 uv, vec2 p, vec2 pointer, float distanceToPointer, float t) {
458
+ vec2 delta = p - pointer;
459
+ float influence = exp(-distanceToPointer * 4.6) * u_motion;
460
+ float angle = influence * 1.7;
461
+ mat2 swirl = mat2(cos(angle), -sin(angle), sin(angle), cos(angle));
462
+ p = pointer + swirl * delta;
463
+ p += normalize(delta + 0.0001) * influence * 0.08;
464
+
465
+ vec2 drift = vec2(t * 0.22, -t * 0.13);
466
+ vec2 q = vec2(
467
+ fbm(p * 1.35 + drift + u_seed),
468
+ fbm(p * 1.35 + vec2(5.2, 1.3) - drift * 0.85)
469
+ );
470
+ vec2 r = vec2(
471
+ fbm(p * 2.0 + 3.6 * q + vec2(1.7, 9.2) + t * 0.10),
472
+ fbm(p * 2.0 + 3.0 * q + vec2(8.3, 2.8) - t * 0.085)
473
+ );
474
+
475
+ float cloud = fbm(p * 1.7 + 4.2 * r);
476
+ float veins = fbm(p * 4.0 - 2.0 * q + t * 0.065);
477
+ float nebula = smoothstep(0.18, 0.91, cloud * 0.9 + veins * 0.22);
478
+
479
+ vec3 color = palette(nebula);
480
+ color += u_colorD * pow(max(cloud - 0.63, 0.0), 2.0) * 1.05;
481
+ color *= 0.78 + 0.34 * smoothstep(0.15, 0.9, veins);
482
+
483
+ vec2 starGrid = floor((uv + vec2(u_seed * 0.013, 0.0)) * vec2(132.0, 58.0));
484
+ vec2 starCell = fract(uv * vec2(132.0, 58.0)) - 0.5;
485
+ float starRandom = hash21(starGrid);
486
+ float starShape = smoothstep(0.075, 0.0, length(starCell));
487
+ float starMask = step(0.989, starRandom) * starShape;
488
+ float twinkle = 0.35 + 0.65 * sin(t * (1.0 + starRandom * 2.4) + starRandom * 40.0) * 0.5 + 0.5;
489
+ color += starMask * twinkle * mix(u_colorC, u_colorD, starRandom) * 1.05;
490
+
491
+ float pointerGlow = exp(-distanceToPointer * 7.0) * u_motion;
492
+ color += u_colorD * pointerGlow * 0.28;
493
+ return color;
494
+ }
495
+
496
+ void main() {
497
+ vec2 uv = v_uv;
498
+ vec2 p = uv - 0.5;
499
+ p.x *= u_resolution.x / max(u_resolution.y, 1.0);
500
+
501
+ vec2 pointer = u_pointer - 0.5;
502
+ pointer.x *= u_resolution.x / max(u_resolution.y, 1.0);
503
+ float distanceToPointer = length(p - pointer);
504
+
505
+ vec3 color = renderNebula(uv, p, pointer, distanceToPointer, u_time);
506
+ float vignette = smoothstep(0.94, 0.18, length((uv - 0.5) * vec2(1.0, 1.35)));
507
+ color *= 0.70 + vignette * 0.42;
508
+ color = pow(max(color, vec3(0.0)), vec3(0.88));
509
+
510
+ outColor = vec4(color, 1.0);
511
+ }`;
512
+
513
+ function compileShader(gl, type, source) {
514
+ const shader = gl.createShader(type);
515
+ gl.shaderSource(shader, source);
516
+ gl.compileShader(shader);
517
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
518
+ const message = gl.getShaderInfoLog(shader) || 'Unknown shader compile error';
519
+ gl.deleteShader(shader);
520
+ throw new Error(message);
521
+ }
522
+ return shader;
523
+ }
524
+
525
+ function createProgram(gl) {
526
+ const vertex = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
527
+ const fragment = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
528
+ const program = gl.createProgram();
529
+ gl.attachShader(program, vertex);
530
+ gl.attachShader(program, fragment);
531
+ gl.linkProgram(program);
532
+ gl.deleteShader(vertex);
533
+ gl.deleteShader(fragment);
534
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
535
+ const message = gl.getProgramInfoLog(program) || 'Unknown program link error';
536
+ gl.deleteProgram(program);
537
+ throw new Error(message);
538
+ }
539
+ return program;
540
+ }
541
+
542
+ class CosmicRenderer {
543
+ constructor(canvas, preset, options = {}) {
544
+ this.canvas = canvas;
545
+ this.preset = { ...preset };
546
+ this.options = { dprCap: 1.75, mouseColor: true, ...options };
547
+ this.gl = canvas.getContext('webgl2', {
548
+ alpha: false,
549
+ antialias: false,
550
+ depth: false,
551
+ powerPreference: this.options.powerPreference || 'high-performance',
552
+ preserveDrawingBuffer: false
553
+ });
554
+ if (!this.gl) throw new Error('WebGL2 is not available');
555
+
556
+ this.program = createProgram(this.gl);
557
+ this.locations = this.#getLocations();
558
+ this.pointer = [0.72, 0.45];
559
+ this.pointerTarget = [...this.pointer];
560
+ this.motion = 0;
561
+ this.motionTarget = 0;
562
+ this.timeOffset = preset.seed * 0.73;
563
+ this.colors = preset.colors.map(hexToRgb01);
564
+ this.visible = true;
565
+ this.disposed = false;
566
+
567
+ this.#setupGeometry();
568
+ this.#bindEvents();
569
+ this.#bindContextEvents();
570
+ this.resize();
571
+ }
572
+
573
+ #getLocations() {
574
+ const gl = this.gl;
575
+ const uniform = (name) => gl.getUniformLocation(this.program, name);
576
+ return {
577
+ position: gl.getAttribLocation(this.program, 'a_position'),
578
+ resolution: uniform('u_resolution'),
579
+ time: uniform('u_time'),
580
+ seed: uniform('u_seed'),
581
+ motion: uniform('u_motion'),
582
+ pointer: uniform('u_pointer'),
583
+ colorA: uniform('u_colorA'),
584
+ colorB: uniform('u_colorB'),
585
+ colorC: uniform('u_colorC'),
586
+ colorD: uniform('u_colorD')
587
+ };
588
+ }
589
+
590
+ #setupGeometry() {
591
+ const gl = this.gl;
592
+ const vertices = new Float32Array([-1, -1, 3, -1, -1, 3]);
593
+ this.buffer = gl.createBuffer();
594
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
595
+ gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
596
+ }
597
+
598
+ #bindEvents() {
599
+ if (this.options.mouseColor === false) return;
600
+ this.eventTarget = this.options.eventTarget || this.canvas.parentElement || this.canvas;
601
+ this.onPointerMove = (event) => {
602
+ const rect = this.canvas.getBoundingClientRect();
603
+ this.pointerTarget[0] = (event.clientX - rect.left) / Math.max(rect.width, 1);
604
+ this.pointerTarget[1] = 1 - (event.clientY - rect.top) / Math.max(rect.height, 1);
605
+ this.motionTarget = 1;
606
+ };
607
+ this.onPointerLeave = () => { this.motionTarget = 0; };
608
+ this.eventTarget.addEventListener('pointermove', this.onPointerMove, { passive: true });
609
+ this.eventTarget.addEventListener('pointerdown', this.onPointerMove, { passive: true });
610
+ this.eventTarget.addEventListener('pointerleave', this.onPointerLeave, { passive: true });
611
+ }
612
+
613
+ #bindContextEvents() {
614
+ this.onContextLost = (event) => {
615
+ event.preventDefault();
616
+ if (this.disposed) return;
617
+ this.visible = false;
618
+ if (typeof this.options.onContextLost === 'function') this.options.onContextLost(event);
619
+ };
620
+ this.onContextRestored = () => {
621
+ if (this.disposed) return;
622
+ this.program = createProgram(this.gl);
623
+ this.locations = this.#getLocations();
624
+ this.#setupGeometry();
625
+ this.visible = true;
626
+ this.resize();
627
+ if (typeof this.options.onContextRestored === 'function') this.options.onContextRestored();
628
+ };
629
+ this.canvas.addEventListener('webglcontextlost', this.onContextLost, false);
630
+ this.canvas.addEventListener('webglcontextrestored', this.onContextRestored, false);
631
+ }
632
+
633
+ setPreset(preset) {
634
+ this.preset = { ...preset };
635
+ this.colors = preset.colors.map(hexToRgb01);
636
+ this.timeOffset = preset.seed * 0.73;
637
+ }
638
+
605
639
  setDprCap(cap) {
606
640
  this.options.dprCap = cap;
607
641
  this.resize();
@@ -627,123 +661,128 @@ class CosmicRenderer {
627
661
  }
628
662
 
629
663
  randomize() {
630
- this.preset.seed = Math.random() * 100;
631
- this.timeOffset = Math.random() * 40;
632
- }
633
-
634
- resize() {
635
- const dpr = Math.min(window.devicePixelRatio || 1, this.options.dprCap);
636
- const rect = this.canvas.getBoundingClientRect();
637
- const width = Math.max(2, Math.round(rect.width * dpr));
638
- const height = Math.max(2, Math.round(rect.height * dpr));
639
- if (this.canvas.width !== width || this.canvas.height !== height) {
640
- this.canvas.width = width;
641
- this.canvas.height = height;
642
- this.gl.viewport(0, 0, width, height);
643
- }
644
- }
645
-
646
- draw(elapsedSeconds, paused = false) {
647
- if (this.disposed || !this.visible) return;
648
- this.resize();
649
- const gl = this.gl;
650
- this.pointer[0] += (this.pointerTarget[0] - this.pointer[0]) * 0.08;
651
- this.pointer[1] += (this.pointerTarget[1] - this.pointer[1]) * 0.08;
652
- this.motion += (this.motionTarget - this.motion) * 0.07;
653
-
654
- gl.useProgram(this.program);
655
- gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
656
- gl.enableVertexAttribArray(this.locations.position);
657
- gl.vertexAttribPointer(this.locations.position, 2, gl.FLOAT, false, 0, 0);
658
-
659
- const colors = this.preset.colors.map(hexToRgb01);
660
- gl.uniform2f(this.locations.resolution, this.canvas.width, this.canvas.height);
661
- gl.uniform1f(this.locations.time, this.timeOffset + (paused ? 0 : elapsedSeconds * this.preset.speed));
662
- gl.uniform1f(this.locations.seed, this.preset.seed);
663
- gl.uniform1f(this.locations.motion, this.motion);
664
- gl.uniform2f(this.locations.pointer, this.pointer[0], this.pointer[1]);
665
- gl.uniform3fv(this.locations.colorA, colors[0]);
666
- gl.uniform3fv(this.locations.colorB, colors[1]);
667
- gl.uniform3fv(this.locations.colorC, colors[2]);
668
- gl.uniform3fv(this.locations.colorD, colors[3]);
669
- gl.drawArrays(gl.TRIANGLES, 0, 3);
670
- }
671
-
672
- dispose() {
673
- this.disposed = true;
674
- const target = this.eventTarget || this.canvas;
675
- target.removeEventListener('pointermove', this.onPointerMove);
676
- target.removeEventListener('pointerdown', this.onPointerMove);
677
- target.removeEventListener('pointerleave', this.onPointerLeave);
678
- this.gl.deleteBuffer(this.buffer);
679
- this.gl.deleteProgram(this.program);
680
- const lose = this.gl.getExtension('WEBGL_lose_context');
681
- if (lose) lose.loseContext();
682
- }
664
+ this.preset.seed = Math.random() * 100;
665
+ this.timeOffset = Math.random() * 40;
666
+ }
667
+
668
+ resize() {
669
+ const dpr = Math.min(window.devicePixelRatio || 1, this.options.dprCap);
670
+ const rect = this.canvas.getBoundingClientRect();
671
+ const width = Math.max(2, Math.round(rect.width * dpr));
672
+ const height = Math.max(2, Math.round(rect.height * dpr));
673
+ if (this.canvas.width !== width || this.canvas.height !== height) {
674
+ this.canvas.width = width;
675
+ this.canvas.height = height;
676
+ }
677
+ this.gl.viewport(0, 0, width, height);
678
+ }
679
+
680
+ draw(elapsedSeconds, paused = false) {
681
+ if (this.disposed || !this.visible) return;
682
+ const gl = this.gl;
683
+ this.pointer[0] += (this.pointerTarget[0] - this.pointer[0]) * 0.08;
684
+ this.pointer[1] += (this.pointerTarget[1] - this.pointer[1]) * 0.08;
685
+ this.motion += (this.motionTarget - this.motion) * 0.07;
686
+
687
+ gl.useProgram(this.program);
688
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
689
+ gl.enableVertexAttribArray(this.locations.position);
690
+ gl.vertexAttribPointer(this.locations.position, 2, gl.FLOAT, false, 0, 0);
691
+
692
+ gl.uniform2f(this.locations.resolution, this.canvas.width, this.canvas.height);
693
+ gl.uniform1f(this.locations.time, this.timeOffset + (paused ? 0 : elapsedSeconds * this.preset.speed));
694
+ gl.uniform1f(this.locations.seed, this.preset.seed);
695
+ gl.uniform1f(this.locations.motion, this.motion);
696
+ gl.uniform2f(this.locations.pointer, this.pointer[0], this.pointer[1]);
697
+ gl.uniform3fv(this.locations.colorA, this.colors[0]);
698
+ gl.uniform3fv(this.locations.colorB, this.colors[1]);
699
+ gl.uniform3fv(this.locations.colorC, this.colors[2]);
700
+ gl.uniform3fv(this.locations.colorD, this.colors[3]);
701
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
702
+ }
703
+
704
+ dispose() {
705
+ this.disposed = true;
706
+ const target = this.eventTarget || this.canvas;
707
+ target.removeEventListener('pointermove', this.onPointerMove);
708
+ target.removeEventListener('pointerdown', this.onPointerMove);
709
+ target.removeEventListener('pointerleave', this.onPointerLeave);
710
+ this.canvas.removeEventListener('webglcontextlost', this.onContextLost, false);
711
+ this.canvas.removeEventListener('webglcontextrestored', this.onContextRestored, false);
712
+ this.gl.deleteBuffer(this.buffer);
713
+ this.gl.deleteProgram(this.program);
714
+ const lose = this.gl.getExtension('WEBGL_lose_context');
715
+ if (lose) lose.loseContext();
716
+ }
717
+ }
718
+
719
+ function rgb(color, alpha = 1) {
720
+ const [r, g, b] = hexToRgb01(color).map((value) => Math.round(value * 255));
721
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
683
722
  }
684
723
 
685
- function rgb(color, alpha = 1) {
686
- const [r, g, b] = hexToRgb01(color).map((value) => Math.round(value * 255));
687
- return `rgba(${r}, ${g}, ${b}, ${alpha})`;
688
- }
689
-
690
- class FallbackRenderer {
691
- constructor(canvas, preset) {
724
+ class FallbackRenderer {
725
+ constructor(canvas, preset, options = {}) {
692
726
  this.canvas = canvas;
693
727
  this.preset = preset;
694
728
  this.context = canvas.getContext('2d');
695
729
  this.visible = true;
696
730
  this.timePhase = 0;
731
+ this.dprCap = options.dprCap || 1.5;
697
732
  }
698
-
699
- resize() {
700
- const rect = this.canvas.getBoundingClientRect();
701
- const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
702
- const width = Math.max(2, Math.round(rect.width * dpr));
703
- const height = Math.max(2, Math.round(rect.height * dpr));
704
- if (this.canvas.width !== width || this.canvas.height !== height) {
705
- this.canvas.width = width;
706
- this.canvas.height = height;
707
- }
708
- }
709
-
733
+
734
+ resize() {
735
+ const rect = this.canvas.getBoundingClientRect();
736
+ const dpr = Math.min(window.devicePixelRatio || 1, this.dprCap);
737
+ const width = Math.max(2, Math.round(rect.width * dpr));
738
+ const height = Math.max(2, Math.round(rect.height * dpr));
739
+ if (this.canvas.width !== width || this.canvas.height !== height) {
740
+ this.canvas.width = width;
741
+ this.canvas.height = height;
742
+ }
743
+ }
744
+
710
745
  drawNebula(time) {
711
746
  const ctx = this.context;
712
747
  const { width, height } = this.canvas;
713
748
  const t = (time + this.timePhase) * (this.preset.speed || 1);
714
749
  const phase = (this.preset.seed || 0) * 0.7;
715
750
  const gradient = ctx.createLinearGradient(0, 0, width, height);
716
- gradient.addColorStop(0, this.preset.colors[0]);
717
- gradient.addColorStop(0.38, this.preset.colors[1]);
718
- gradient.addColorStop(0.72, this.preset.colors[2]);
719
- gradient.addColorStop(1, this.preset.colors[3]);
720
- ctx.fillStyle = gradient;
721
- ctx.fillRect(0, 0, width, height);
722
-
751
+ gradient.addColorStop(0, this.preset.colors[0]);
752
+ gradient.addColorStop(0.38, this.preset.colors[1]);
753
+ gradient.addColorStop(0.72, this.preset.colors[2]);
754
+ gradient.addColorStop(1, this.preset.colors[3]);
755
+ ctx.fillStyle = gradient;
756
+ ctx.fillRect(0, 0, width, height);
757
+
723
758
  ctx.globalCompositeOperation = 'screen';
724
759
  for (let index = 0; index < 6; index += 1) {
725
760
  const x = (0.5 + 0.45 * Math.sin(t * 0.32 + index * 1.7 + phase)) * width;
726
761
  const y = (0.5 + 0.4 * Math.cos(t * 0.25 + index + phase)) * height;
727
- const radius = Math.max(width, height) * (0.08 + (index % 3) * 0.04);
728
- const glow = ctx.createRadialGradient(x, y, 0, x, y, radius);
729
- glow.addColorStop(0, rgb(this.preset.colors[(index + 1) % 4], 0.24));
730
- glow.addColorStop(1, rgb(this.preset.colors[index % 4], 0));
731
- ctx.fillStyle = glow;
732
- ctx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
733
- }
734
- ctx.globalCompositeOperation = 'source-over';
735
- }
736
-
737
- draw(time) {
738
- if (!this.visible) return;
739
- this.resize();
740
- this.drawNebula(time);
741
- }
742
-
743
- setPreset(preset) {
744
- this.preset = preset;
745
- }
746
-
762
+ const radius = Math.max(width, height) * (0.08 + (index % 3) * 0.04);
763
+ const glow = ctx.createRadialGradient(x, y, 0, x, y, radius);
764
+ glow.addColorStop(0, rgb(this.preset.colors[(index + 1) % 4], 0.24));
765
+ glow.addColorStop(1, rgb(this.preset.colors[index % 4], 0));
766
+ ctx.fillStyle = glow;
767
+ ctx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
768
+ }
769
+ ctx.globalCompositeOperation = 'source-over';
770
+ }
771
+
772
+ draw(time) {
773
+ if (!this.visible) return;
774
+ this.drawNebula(time);
775
+ }
776
+
777
+ setPreset(preset) {
778
+ this.preset = preset;
779
+ }
780
+
781
+ setDprCap(cap) {
782
+ this.dprCap = cap;
783
+ this.resize();
784
+ }
785
+
747
786
  randomize() {
748
787
  if (this.preset) {
749
788
  this.preset.seed = Math.random() * 100;
@@ -754,101 +793,119 @@ class FallbackRenderer {
754
793
  dispose() {}
755
794
  }
756
795
 
757
- /**
758
- * Document-level shared rAF scheduler. Every component instance subscribes
759
- * its own frame callback; the whole page runs ONE animation loop (like the
760
- * original demo), which avoids jank from many competing rAF loops.
761
- */
762
- const subscribers = [];
763
- let running = false;
764
- let rafId = 0;
765
- let last = 0;
766
-
767
- function tick(now) {
768
- if (!running) return;
769
- const delta = last ? Math.min((now - last) / 1000, 0.05) : 0;
770
- last = now;
771
- // Schedule the next frame BEFORE running callbacks so one throwing
772
- // subscriber can never kill the whole animation loop.
773
- rafId = requestAnimationFrame(tick);
774
- {
775
- for (const item of subscribers.slice()) {
776
- try {
777
- if (!item.isPaused()) item.onFrame(delta, now);
778
- } catch (error) {
796
+ /**
797
+ * Document-level shared rAF scheduler. Every component instance subscribes
798
+ * its own frame callback; the whole page runs ONE animation loop (like the
799
+ * original demo), which avoids jank from many competing rAF loops.
800
+ */
801
+ const subscribers = [];
802
+ let running = false;
803
+ let rafId = 0;
804
+ let last = 0;
805
+
806
+ function tick(now) {
807
+ if (!running) return;
808
+ const activeItems = [];
809
+ {
810
+ for (const item of subscribers.slice()) {
811
+ try {
812
+ if (!item.isPaused()) activeItems.push(item);
813
+ } catch {}
814
+ }
815
+ }
816
+ if (activeItems.length === 0) {
817
+ running = false;
818
+ rafId = 0;
819
+ last = 0;
820
+ return;
821
+ }
822
+ const delta = last ? Math.min((now - last) / 1000, 0.05) : 0;
823
+ last = now;
824
+ // Schedule the next frame BEFORE running callbacks so one throwing
825
+ // subscriber can never kill the whole animation loop.
826
+ rafId = requestAnimationFrame(tick);
827
+ for (const item of activeItems) {
828
+ try {
829
+ item.onFrame(delta, now);
830
+ } catch (error) {
779
831
  console.warn('[dlc-ui] frame error:', error);
780
- }
781
- }
782
- }
783
- if (subscribers.length === 0) {
784
- cancelAnimationFrame(rafId);
785
- running = false;
786
- rafId = 0;
787
- }
788
- }
789
-
790
- function start() {
791
- if (running) return;
792
- running = true;
793
- last = 0;
794
- rafId = requestAnimationFrame(tick);
795
- }
796
-
797
- function subscribeScheduler(onFrame, isPaused) {
798
- const item = { onFrame, isPaused };
799
- subscribers.push(item);
800
- start();
801
- return () => {
802
- const index = subscribers.indexOf(item);
803
- if (index !== -1) subscribers.splice(index, 1);
804
- if (subscribers.length === 0 && rafId) {
805
- cancelAnimationFrame(rafId);
806
- running = false;
807
- rafId = 0;
808
- }
809
- };
832
+ }
833
+ }
834
+ if (subscribers.length === 0) {
835
+ cancelAnimationFrame(rafId);
836
+ running = false;
837
+ rafId = 0;
838
+ }
839
+ }
840
+
841
+ function start() {
842
+ if (running) return;
843
+ running = true;
844
+ last = 0;
845
+ rafId = requestAnimationFrame(tick);
846
+ }
847
+
848
+ function wakeScheduler() {
849
+ start();
850
+ }
851
+
852
+ function subscribeScheduler(onFrame, isPaused) {
853
+ const item = { onFrame, isPaused };
854
+ subscribers.push(item);
855
+ start();
856
+ return () => {
857
+ const index = subscribers.indexOf(item);
858
+ if (index !== -1) subscribers.splice(index, 1);
859
+ if (subscribers.length === 0 && rafId) {
860
+ cancelAnimationFrame(rafId);
861
+ running = false;
862
+ rafId = 0;
863
+ }
864
+ };
810
865
  }
811
866
 
812
- /**
813
- * Gates drawing on "element intersects viewport AND the page tab is visible".
814
- * Falls back to always-visible when IntersectionObserver is unavailable.
815
- */
816
- function createVisibilityGuard(element) {
817
- let intersecting = true;
818
- let pageVisible = typeof document === 'undefined' || !document.hidden;
819
- let disposed = false;
820
- let observer = null;
821
-
822
- if (typeof IntersectionObserver !== 'undefined') {
823
- observer = new IntersectionObserver(
824
- (entries) => {
825
- intersecting = entries.some((entry) => entry.isIntersecting);
826
- },
827
- { rootMargin: '180px' }
828
- );
829
- observer.observe(element);
830
- }
831
-
832
- const onVisibilityChange = () => {
833
- pageVisible = typeof document !== 'undefined' && !document.hidden;
834
- };
835
- if (typeof document !== 'undefined') {
836
- document.addEventListener('visibilitychange', onVisibilityChange);
837
- }
838
-
839
- return {
840
- isVisible() {
841
- return intersecting && pageVisible;
842
- },
843
- dispose() {
844
- if (disposed) return;
845
- disposed = true;
846
- if (observer) observer.disconnect();
847
- if (typeof document !== 'undefined') {
848
- document.removeEventListener('visibilitychange', onVisibilityChange);
849
- }
850
- }
851
- };
867
+ /**
868
+ * Gates drawing on "element intersects viewport AND the page tab is visible".
869
+ * Falls back to always-visible when IntersectionObserver is unavailable.
870
+ */
871
+ function createVisibilityGuard(element, onChange = null) {
872
+ let intersecting = true;
873
+ let pageVisible = typeof document === 'undefined' || !document.hidden;
874
+ let disposed = false;
875
+ let observer = null;
876
+
877
+ if (typeof IntersectionObserver !== 'undefined') {
878
+ observer = new IntersectionObserver(
879
+ (entries) => {
880
+ intersecting = entries.some((entry) => entry.isIntersecting);
881
+ if (typeof onChange === 'function') onChange();
882
+ },
883
+ { rootMargin: '180px' }
884
+ );
885
+ observer.observe(element);
886
+ }
887
+
888
+ const onVisibilityChange = () => {
889
+ pageVisible = typeof document !== 'undefined' && !document.hidden;
890
+ if (typeof onChange === 'function') onChange();
891
+ };
892
+ if (typeof document !== 'undefined') {
893
+ document.addEventListener('visibilitychange', onVisibilityChange);
894
+ }
895
+
896
+ return {
897
+ isVisible() {
898
+ return intersecting && pageVisible;
899
+ },
900
+ dispose() {
901
+ if (disposed) return;
902
+ disposed = true;
903
+ if (observer) observer.disconnect();
904
+ if (typeof document !== 'undefined') {
905
+ document.removeEventListener('visibilitychange', onVisibilityChange);
906
+ }
907
+ }
908
+ };
852
909
  }
853
910
 
854
911
  /**
@@ -867,30 +924,96 @@ function nextTick(fn) {
867
924
  }
868
925
  }
869
926
 
870
- function prefersReducedMotion() {
871
- return typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
872
- }
873
-
874
- /**
927
+ function normalizeFps(value, fallback = 60) {
928
+ const fps = Number(value);
929
+ if (!Number.isFinite(fps)) return fallback;
930
+ return Math.min(60, Math.max(1, fps));
931
+ }
932
+
933
+ function createFrameGate(initialFps = 60) {
934
+ let fps = normalizeFps(initialFps);
935
+ let elapsed = 0;
936
+ return {
937
+ shouldDraw(delta) {
938
+ elapsed += delta;
939
+ const interval = 1 / fps;
940
+ if (elapsed + 0.0001 < interval) return false;
941
+ elapsed %= interval;
942
+ return true;
943
+ },
944
+ setFps(value) {
945
+ fps = normalizeFps(value, fps);
946
+ elapsed = 0;
947
+ return fps;
948
+ },
949
+ getFps() {
950
+ return fps;
951
+ }
952
+ };
953
+ }
954
+
955
+ function createReducedMotionPreference(enabled, onChange) {
956
+ const query = enabled && typeof matchMedia !== 'undefined'
957
+ ? matchMedia('(prefers-reduced-motion: reduce)')
958
+ : null;
959
+ const notify = () => {
960
+ if (typeof onChange === 'function') onChange(Boolean(query && query.matches));
961
+ };
962
+ if (query) {
963
+ if (typeof query.addEventListener === 'function') query.addEventListener('change', notify);
964
+ else if (typeof query.addListener === 'function') query.addListener(notify);
965
+ }
966
+ return {
967
+ matches() {
968
+ return Boolean(query && query.matches);
969
+ },
970
+ dispose() {
971
+ if (!query) return;
972
+ if (typeof query.removeEventListener === 'function') query.removeEventListener('change', notify);
973
+ else if (typeof query.removeListener === 'function') query.removeListener(notify);
974
+ }
975
+ };
976
+ }
977
+
978
+ function toFiniteNumber(value) {
979
+ if (value == null || typeof value === 'boolean') return null;
980
+ if (typeof value === 'string' && value.trim() === '') return null;
981
+ const number = Number(value);
982
+ return Number.isFinite(number) ? number : null;
983
+ }
984
+
985
+ /**
875
986
  * Mount a cosmic (nebula) capsule into `container`.
876
987
  *
877
988
  * Options: preset (literary name), width, height (number=px or string with
878
989
  * px/%/vw/vh/em/rem), colors, seed, speed, textRatio (0-100, text region
879
990
  * width in percent), text (HTML string or DOM nodes for the text slot),
880
991
  * colorContent (HTML string or DOM nodes for the color slot), quality,
881
- * renderer, respectReducedMotion, mouseColor, cssVars.
992
+ * renderer, quality, renderScale, powerPreference, fps, paused/static,
993
+ * respectReducedMotion, mouseColor, cssVars.
882
994
  */
883
995
  function createCapsule(container, options = {}) {
884
- if (!container || typeof container.appendChild !== 'function') {
885
- throw new Error('createCapsule: container element is required');
886
- }
887
-
996
+ if (!container || typeof container.appendChild !== 'function') {
997
+ throw new Error('createCapsule: container element is required');
998
+ }
999
+
888
1000
  const preset = { ...getPreset('capsule', options.preset ?? '初光') };
889
1001
  const merged = normalizeOptions(DEFAULTS.capsule, preset, options);
890
- const normalizedColors = merged.colors.map(normalizeColor);
891
- if (normalizedColors.every(Boolean)) preset.colors = normalizedColors;
892
- preset.seed = merged.seed;
893
- preset.speed = merged.speed;
1002
+ const normalizedColors = Array.isArray(merged.colors) ? merged.colors.map(normalizeColor) : [];
1003
+ if (normalizedColors.length === 4 && normalizedColors.every(Boolean)) preset.colors = normalizedColors;
1004
+ const initialSeed = toFiniteNumber(merged.seed);
1005
+ const initialSpeed = toFiniteNumber(merged.speed);
1006
+ if (initialSeed !== null) preset.seed = initialSeed;
1007
+ if (initialSpeed !== null) preset.speed = initialSpeed;
1008
+ merged.colors = [...preset.colors];
1009
+ merged.seed = preset.seed;
1010
+ merged.speed = preset.speed;
1011
+ const optionColors = Array.isArray(options.colors) ? options.colors.map(normalizeColor) : [];
1012
+ let colorOverride = optionColors.length === 4 && optionColors.every(Boolean)
1013
+ ? [...optionColors]
1014
+ : null;
1015
+ let seedOverride = options.seed !== undefined ? toFiniteNumber(options.seed) : null;
1016
+ let speedOverride = options.speed !== undefined ? toFiniteNumber(options.speed) : null;
894
1017
  let customLabel = typeof options.label === 'string' && options.label ? options.label : null;
895
1018
 
896
1019
  const root = document.createElement('div');
@@ -938,35 +1061,67 @@ function createCapsule(container, options = {}) {
938
1061
  root.appendChild(visualLayer);
939
1062
  root.appendChild(canvas);
940
1063
  container.appendChild(root);
941
-
1064
+
942
1065
  const emitter = createEmitter();
943
- let paused = merged.respectReducedMotion && prefersReducedMotion();
1066
+ let manuallyPaused = merged.paused === true;
1067
+ let reducedPaused = false;
1068
+ let staticMode = merged.static === true;
1069
+ let contextLost = false;
944
1070
  let disposed = false;
1071
+ let renderOnce = () => {};
945
1072
  const dirty = {
946
1073
  preset: false,
947
1074
  seed: false,
948
1075
  speed: false,
949
1076
  colors: false,
950
1077
  textRatio: false,
951
- cssVars: false
1078
+ cssVars: false,
1079
+ mouseColor: false,
1080
+ quality: false,
1081
+ renderScale: false,
1082
+ paused: false,
1083
+ static: false,
1084
+ fps: false
952
1085
  };
953
-
954
- let renderer;
955
- const useWebgl = merged.renderer !== 'canvas2d';
956
- if (useWebgl) {
957
- try {
958
- renderer = new CosmicRenderer(canvas, merged, { dprCap: dprCapFor(merged.quality) });
959
- } catch (error) {
960
- renderer = new FallbackRenderer(canvas, merged);
1086
+
1087
+ let renderer;
1088
+ const useWebgl = merged.renderer !== 'canvas2d';
1089
+ if (useWebgl) {
1090
+ try {
1091
+ renderer = new CosmicRenderer(canvas, merged, {
1092
+ dprCap: effectiveDprCap(merged.quality, merged.renderScale),
1093
+ mouseColor: merged.mouseColor !== false,
1094
+ powerPreference: merged.powerPreference,
1095
+ onContextLost: () => {
1096
+ contextLost = true;
1097
+ emitter.emit('contextlost', {});
1098
+ },
1099
+ onContextRestored: () => {
1100
+ contextLost = false;
1101
+ emitter.emit('contextrestored', {});
1102
+ renderOnce();
1103
+ wakeScheduler();
1104
+ }
1105
+ });
1106
+ } catch (error) {
1107
+ renderer = new FallbackRenderer(canvas, merged, {
1108
+ dprCap: effectiveDprCap(merged.quality, merged.renderScale)
1109
+ });
961
1110
  nextTick(() => {
962
1111
  if (disposed) return;
963
1112
  emitter.emit('error', { message: String(error && error.message ? error.message : error) });
964
1113
  });
965
- }
966
- } else {
967
- renderer = new FallbackRenderer(canvas, merged);
968
- }
969
-
1114
+ }
1115
+ } else {
1116
+ renderer = new FallbackRenderer(canvas, merged, {
1117
+ dprCap: effectiveDprCap(merged.quality, merged.renderScale)
1118
+ });
1119
+ }
1120
+
1121
+ let animationTime = 0;
1122
+ const frameGate = createFrameGate(merged.fps);
1123
+ renderOnce = () => renderer.draw(animationTime);
1124
+
970
1125
  const applySize = () => {
971
1126
  root.style.width = parseSize(merged.width);
972
1127
  root.style.height = parseSize(merged.height);
@@ -980,39 +1135,53 @@ function createCapsule(container, options = {}) {
980
1135
  }
981
1136
  renderer.resize();
982
1137
  };
983
- applySize();
984
-
985
- const resizeObserver = typeof ResizeObserver !== 'undefined'
986
- ? new ResizeObserver(() => renderer.resize())
987
- : null;
988
- if (resizeObserver) resizeObserver.observe(root);
989
- else window.addEventListener('resize', renderer.resize);
990
-
991
- const visibility = createVisibilityGuard(root);
992
-
1138
+ applySize();
1139
+
1140
+ const resize = () => {
1141
+ renderer.resize();
1142
+ if (manuallyPaused || reducedPaused || staticMode) renderOnce();
1143
+ };
1144
+ const resizeObserver = typeof ResizeObserver !== 'undefined'
1145
+ ? new ResizeObserver(resize)
1146
+ : null;
1147
+ if (resizeObserver) resizeObserver.observe(root);
1148
+ else window.addEventListener('resize', resize);
1149
+
1150
+ const visibility = createVisibilityGuard(root, wakeScheduler);
1151
+ const motionPreference = createReducedMotionPreference(
1152
+ merged.respectReducedMotion,
1153
+ (matches) => {
1154
+ reducedPaused = matches;
1155
+ if (matches) renderOnce();
1156
+ else wakeScheduler();
1157
+ }
1158
+ );
1159
+ reducedPaused = motionPreference.matches();
1160
+ const isMotionPaused = () => manuallyPaused || reducedPaused || staticMode || contextLost;
1161
+
993
1162
  root.addEventListener('pointerenter', () => emitter.emit('pointerenter', { preset: { ...preset } }));
994
1163
  root.addEventListener('pointerleave', () => emitter.emit('pointerleave', { preset: { ...preset } }));
995
- root.addEventListener('click', (event) => {
996
- emitter.emit('click', { event, preset: { ...preset } });
997
- });
998
- root.addEventListener('pointerdown', (event) => emitter.emit('pointerdown', { event, preset: { ...preset } }));
999
- root.addEventListener('pointerup', (event) => emitter.emit('pointerup', { event, preset: { ...preset } }));
1000
- root.addEventListener('dblclick', (event) => emitter.emit('dblclick', { event, preset: { ...preset } }));
1001
-
1002
- let animationTime = 0;
1003
- const unsubscribe = subscribeScheduler(
1004
- (delta) => {
1005
- animationTime += delta;
1006
- if (visibility.isVisible()) renderer.draw(animationTime);
1007
- },
1008
- () => paused
1009
- );
1010
-
1164
+ root.addEventListener('click', (event) => {
1165
+ emitter.emit('click', { event, preset: { ...preset } });
1166
+ });
1167
+ root.addEventListener('pointerdown', (event) => emitter.emit('pointerdown', { event, preset: { ...preset } }));
1168
+ root.addEventListener('pointerup', (event) => emitter.emit('pointerup', { event, preset: { ...preset } }));
1169
+ root.addEventListener('dblclick', (event) => emitter.emit('dblclick', { event, preset: { ...preset } }));
1170
+
1171
+ renderOnce();
1172
+ const unsubscribe = subscribeScheduler(
1173
+ (delta) => {
1174
+ animationTime += delta;
1175
+ if (frameGate.shouldDraw(delta)) renderer.draw(animationTime);
1176
+ },
1177
+ () => isMotionPaused() || !visibility.isVisible()
1178
+ );
1179
+
1011
1180
  nextTick(() => {
1012
1181
  if (disposed) return;
1013
1182
  emitter.emit('ready', { preset: { ...preset } });
1014
1183
  });
1015
-
1184
+
1016
1185
  const syncTheme = () => {
1017
1186
  root.dataset.mode = 'nebula';
1018
1187
  root.dataset.theme = preset.theme || 'light';
@@ -1020,45 +1189,55 @@ function createCapsule(container, options = {}) {
1020
1189
  };
1021
1190
 
1022
1191
  return {
1023
- element: root,
1024
- canvas,
1025
- preset,
1026
- on: emitter.on,
1027
- off: emitter.off,
1192
+ element: root,
1193
+ canvas,
1194
+ preset,
1195
+ on: emitter.on,
1196
+ off: emitter.off,
1028
1197
  setPreset(ref) {
1029
1198
  const next = getPreset('capsule', ref);
1030
1199
  dirty.preset = true;
1031
1200
  Object.assign(preset, next);
1201
+ if (colorOverride) preset.colors = [...colorOverride];
1202
+ if (seedOverride !== null) preset.seed = seedOverride;
1203
+ if (speedOverride !== null) preset.speed = speedOverride;
1032
1204
  const nextColors = preset.colors.map(normalizeColor);
1033
1205
  if (nextColors.every(Boolean)) preset.colors = nextColors;
1034
1206
  renderer.setPreset({ ...preset });
1035
1207
  syncTheme();
1036
1208
  renderer.resize();
1209
+ if (isMotionPaused()) renderOnce();
1037
1210
  emitter.emit('presetchange', { preset: { ...next } });
1038
1211
  return this;
1039
- },
1212
+ },
1040
1213
  setColors(colors) {
1041
1214
  const next = Array.isArray(colors) ? colors.map(normalizeColor) : [];
1042
1215
  if (next.length !== 4 || next.some((color) => !color)) return this;
1043
1216
  dirty.colors = true;
1217
+ colorOverride = [...next];
1044
1218
  preset.colors = next;
1045
1219
  renderer.setPreset({ ...preset, colors: next });
1220
+ if (isMotionPaused()) renderOnce();
1046
1221
  return this;
1047
1222
  },
1048
1223
  setSeed(seed) {
1049
- const value = Number(seed);
1050
- if (!Number.isFinite(value)) return this;
1224
+ const value = toFiniteNumber(seed);
1225
+ if (value === null) return this;
1051
1226
  dirty.seed = true;
1227
+ seedOverride = value;
1052
1228
  preset.seed = value;
1053
1229
  renderer.setPreset({ ...preset });
1230
+ if (isMotionPaused()) renderOnce();
1054
1231
  return this;
1055
1232
  },
1056
1233
  setSpeed(speed) {
1057
- const value = Number(speed);
1058
- if (!Number.isFinite(value)) return this;
1234
+ const value = toFiniteNumber(speed);
1235
+ if (value === null) return this;
1059
1236
  dirty.speed = true;
1237
+ speedOverride = value;
1060
1238
  preset.speed = value;
1061
1239
  renderer.setPreset({ ...preset });
1240
+ if (isMotionPaused()) renderOnce();
1062
1241
  return this;
1063
1242
  },
1064
1243
  setText(content) {
@@ -1091,6 +1270,7 @@ function createCapsule(container, options = {}) {
1091
1270
  return this;
1092
1271
  },
1093
1272
  setMouseColor(value) {
1273
+ dirty.mouseColor = true;
1094
1274
  merged.mouseColor = value !== false;
1095
1275
  if (typeof renderer.setMouseColor === 'function') renderer.setMouseColor(merged.mouseColor);
1096
1276
  return this;
@@ -1105,36 +1285,80 @@ function createCapsule(container, options = {}) {
1105
1285
  merged.height = height;
1106
1286
  }
1107
1287
  applySize();
1288
+ if (isMotionPaused()) renderOnce();
1108
1289
  return this;
1109
1290
  },
1110
1291
  randomize() {
1111
1292
  this.setSeed(Math.random() * 100);
1112
1293
  return this;
1113
1294
  },
1114
- pause() {
1115
- paused = true;
1116
- return this;
1117
- },
1118
- resume() {
1119
- paused = false;
1120
- return this;
1121
- },
1122
- setQuality(quality) {
1123
- merged.quality = quality;
1124
- if (typeof renderer.setDprCap === 'function') renderer.setDprCap(dprCapFor(quality));
1125
- return this;
1126
- },
1295
+ pause() {
1296
+ dirty.paused = true;
1297
+ manuallyPaused = true;
1298
+ renderOnce();
1299
+ return this;
1300
+ },
1301
+ resume() {
1302
+ dirty.paused = true;
1303
+ manuallyPaused = false;
1304
+ wakeScheduler();
1305
+ return this;
1306
+ },
1307
+ setPaused(value) {
1308
+ return value ? this.pause() : this.resume();
1309
+ },
1310
+ setStatic(value) {
1311
+ dirty.static = true;
1312
+ staticMode = value === true;
1313
+ merged.static = staticMode;
1314
+ if (staticMode) renderOnce();
1315
+ else wakeScheduler();
1316
+ return this;
1317
+ },
1318
+ setFps(fps) {
1319
+ dirty.fps = true;
1320
+ merged.fps = frameGate.setFps(fps);
1321
+ wakeScheduler();
1322
+ return this;
1323
+ },
1324
+ setQuality(quality) {
1325
+ dirty.quality = true;
1326
+ merged.quality = quality;
1327
+ if (typeof renderer.setDprCap === 'function') {
1328
+ renderer.setDprCap(effectiveDprCap(quality, merged.renderScale));
1329
+ }
1330
+ if (isMotionPaused()) renderOnce();
1331
+ return this;
1332
+ },
1333
+ setRenderScale(renderScale) {
1334
+ const value = Number(renderScale);
1335
+ if (!Number.isFinite(value)) return this;
1336
+ dirty.renderScale = true;
1337
+ merged.renderScale = Math.min(1, Math.max(0.25, value));
1338
+ if (typeof renderer.setDprCap === 'function') {
1339
+ renderer.setDprCap(effectiveDprCap(merged.quality, merged.renderScale));
1340
+ }
1341
+ if (isMotionPaused()) renderOnce();
1342
+ return this;
1343
+ },
1127
1344
  dispose() {
1128
1345
  disposed = true;
1129
1346
  unsubscribe();
1130
- visibility.dispose();
1131
- if (resizeObserver) resizeObserver.disconnect();
1132
- else window.removeEventListener('resize', renderer.resize);
1347
+ visibility.dispose();
1348
+ motionPreference.dispose();
1349
+ if (resizeObserver) resizeObserver.disconnect();
1350
+ else window.removeEventListener('resize', resize);
1133
1351
  renderer.dispose();
1134
1352
  root.remove();
1135
1353
  },
1136
1354
  get textRatio() { return merged.textRatio; },
1137
1355
  get cssVars() { return merged.cssVars; },
1356
+ get mouseColor() { return merged.mouseColor; },
1357
+ get quality() { return merged.quality; },
1358
+ get renderScale() { return merged.renderScale; },
1359
+ get paused() { return manuallyPaused; },
1360
+ get static() { return staticMode; },
1361
+ get fps() { return frameGate.getFps(); },
1138
1362
  dirty
1139
1363
  };
1140
1364
  }