@promptbook/components 0.112.0-117 → 0.112.0-118

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/esm/index.es.js CHANGED
@@ -40,7 +40,7 @@ const BOOK_LANGUAGE_VERSION = '2.0.0';
40
40
  * @generated
41
41
  * @see https://github.com/webgptorg/promptbook
42
42
  */
43
- const PROMPTBOOK_ENGINE_VERSION = '0.112.0-117';
43
+ const PROMPTBOOK_ENGINE_VERSION = '0.112.0-118';
44
44
  /**
45
45
  * TODO: string_promptbook_version should be constrained to the all versions of Promptbook engine
46
46
  * Note: [💞] Ignore a discrepancy between file name and entity name
@@ -2776,21 +2776,22 @@ function getPointBounds(points) {
2776
2776
  * @private helper of `fractalAvatarVisual`
2777
2777
  */
2778
2778
  function drawDragonCurveLayer(context, points, options) {
2779
- const { size, primaryColor, secondaryColor, tertiaryColor, shadowColor, strokeWidth, timeMs, layerIndex } = options;
2779
+ const { primaryColor, secondaryColor, tertiaryColor, shadowColor, strokeWidth, timeMs, layerIndex } = options;
2780
2780
  const firstPoint = points[0];
2781
2781
  const lastPoint = points[points.length - 1];
2782
2782
  const ribbonGradient = context.createLinearGradient(firstPoint.x, firstPoint.y, lastPoint.x, lastPoint.y);
2783
2783
  ribbonGradient.addColorStop(0, `${primaryColor}f2`);
2784
2784
  ribbonGradient.addColorStop(0.5, `${secondaryColor}e6`);
2785
2785
  ribbonGradient.addColorStop(1, `${tertiaryColor}f2`);
2786
+ // Approximate the blurred shadow stroke with a wider semi-transparent stroke instead of
2787
+ // context.filter blur, which triggers a costly software rasterization pass every frame.
2786
2788
  context.save();
2787
2789
  context.beginPath();
2788
2790
  tracePolyline(context, points);
2789
- context.strokeStyle = `${shadowColor}82`;
2790
- context.lineWidth = strokeWidth * 1.8;
2791
+ context.strokeStyle = `${shadowColor}48`;
2792
+ context.lineWidth = strokeWidth * 4.5;
2791
2793
  context.lineJoin = 'round';
2792
2794
  context.lineCap = 'round';
2793
- context.filter = `blur(${size * 0.022}px)`;
2794
2795
  context.stroke();
2795
2796
  context.restore();
2796
2797
  context.beginPath();
@@ -3374,11 +3375,23 @@ function drawMinecraftBackdrop(context, size, palette, sceneCenterX, spotlightY,
3374
3375
  * @private helper of `minecraft2AvatarVisual`
3375
3376
  */
3376
3377
  function drawMinecraftShadow(context, size, palette, interaction, timeMs) {
3378
+ const cx = size * 0.5 + interaction.gazeX * size * 0.03;
3379
+ const cy = size * 0.85 + Math.sin(timeMs / 880) * size * 0.01;
3380
+ const rx = size * (0.16 + interaction.intensity * 0.015);
3381
+ const ry = size * 0.055;
3382
+ // Radial gradient approximates the blurry ellipse shadow without context.filter blur.
3377
3383
  context.save();
3378
- context.fillStyle = `${palette.shadow}66`;
3379
- context.filter = `blur(${size * 0.02}px)`;
3384
+ context.translate(cx, cy);
3385
+ context.scale(1, ry / rx);
3386
+ const blurRadius = rx * 1.4;
3387
+ const shadowGradient = context.createRadialGradient(0, 0, 0, 0, 0, blurRadius);
3388
+ shadowGradient.addColorStop(0, `${palette.shadow}7a`);
3389
+ shadowGradient.addColorStop(0.45, `${palette.shadow}44`);
3390
+ shadowGradient.addColorStop(0.8, `${palette.shadow}1a`);
3391
+ shadowGradient.addColorStop(1, `${palette.shadow}00`);
3392
+ context.fillStyle = shadowGradient;
3380
3393
  context.beginPath();
3381
- context.ellipse(size * 0.5 + interaction.gazeX * size * 0.03, size * 0.85 + Math.sin(timeMs / 880) * size * 0.01, size * (0.16 + interaction.intensity * 0.015), size * 0.055, 0, 0, Math.PI * 2);
3394
+ context.arc(0, 0, blurRadius, 0, Math.PI * 2);
3382
3395
  context.fill();
3383
3396
  context.restore();
3384
3397
  }
@@ -3602,13 +3615,27 @@ const minecraftAvatarVisual = {
3602
3615
  spotlight.addColorStop(1, `${palette.highlight}00`);
3603
3616
  context.fillStyle = spotlight;
3604
3617
  context.fillRect(0, 0, size, size);
3605
- context.save();
3606
- context.fillStyle = 'rgba(0, 0, 0, 0.22)';
3607
- context.filter = `blur(${size * 0.018}px)`;
3608
- context.beginPath();
3609
- context.ellipse(size * 0.5, size * 0.86, size * 0.2, size * 0.06, 0, 0, Math.PI * 2);
3610
- context.fill();
3611
- context.restore();
3618
+ {
3619
+ // Radial gradient approximates the blurry ellipse shadow without context.filter blur.
3620
+ const cx = size * 0.5;
3621
+ const cy = size * 0.86;
3622
+ const rx = size * 0.2;
3623
+ const ry = size * 0.06;
3624
+ const blurRadius = rx * 1.4;
3625
+ const shadowGradient = context.createRadialGradient(0, 0, 0, 0, 0, blurRadius);
3626
+ shadowGradient.addColorStop(0, 'rgba(0,0,0,0.28)');
3627
+ shadowGradient.addColorStop(0.45, 'rgba(0,0,0,0.14)');
3628
+ shadowGradient.addColorStop(0.8, 'rgba(0,0,0,0.05)');
3629
+ shadowGradient.addColorStop(1, 'rgba(0,0,0,0)');
3630
+ context.save();
3631
+ context.translate(cx, cy);
3632
+ context.scale(1, ry / rx);
3633
+ context.fillStyle = shadowGradient;
3634
+ context.beginPath();
3635
+ context.arc(0, 0, blurRadius, 0, Math.PI * 2);
3636
+ context.fill();
3637
+ context.restore();
3638
+ }
3612
3639
  drawVoxelCuboid(context, {
3613
3640
  x: bodyX,
3614
3641
  y: bodyY,
@@ -4660,6 +4687,35 @@ const LIGHT_DIRECTION$2 = normalizeVector3({
4660
4687
  y: -0.62,
4661
4688
  z: 0.94,
4662
4689
  });
4690
+ /**
4691
+ * Cache keyed by the `createRandom` factory reference (stable per mounted `<Avatar/>`).
4692
+ *
4693
+ * @private helper of `octopus3dAvatarVisual`
4694
+ */
4695
+ const octopus3dStableStateCache = new WeakMap();
4696
+ /**
4697
+ * Returns the stable per-avatar state, computing it on first access and caching for subsequent frames.
4698
+ *
4699
+ * @private helper of `octopus3dAvatarVisual`
4700
+ */
4701
+ function getOctopus3dStableState(createRandom) {
4702
+ const cached = octopus3dStableStateCache.get(createRandom);
4703
+ if (cached !== undefined) {
4704
+ return cached;
4705
+ }
4706
+ const animationRandom = createRandom('octopus3d-animation-profile');
4707
+ const eyeRandom = createRandom('octopus3d-eye-profile');
4708
+ const leftEyePhaseOffset = eyeRandom() * 0.6;
4709
+ const rightEyePhaseOffset = eyeRandom() * 0.6;
4710
+ const state = {
4711
+ morphologyProfile: createOctopus3MorphologyProfile(createRandom),
4712
+ animationPhase: animationRandom() * Math.PI * 2,
4713
+ leftEyePhaseOffset,
4714
+ rightEyePhaseOffset,
4715
+ };
4716
+ octopus3dStableStateCache.set(createRandom, state);
4717
+ return state;
4718
+ }
4663
4719
  /**
4664
4720
  * Proper 3D Octopus visual built from projected organic meshes and tentacles.
4665
4721
  *
@@ -4672,10 +4728,7 @@ const octopus3dAvatarVisual = {
4672
4728
  isAnimated: true,
4673
4729
  supportsPointerTracking: true,
4674
4730
  render({ context, size, palette, createRandom, timeMs, interaction }) {
4675
- const morphologyProfile = createOctopus3MorphologyProfile(createRandom);
4676
- const animationRandom = createRandom('octopus3d-animation-profile');
4677
- const eyeRandom = createRandom('octopus3d-eye-profile');
4678
- const animationPhase = animationRandom() * Math.PI * 2;
4731
+ const { morphologyProfile, animationPhase, leftEyePhaseOffset, rightEyePhaseOffset } = getOctopus3dStableState(createRandom);
4679
4732
  const sceneCenterX = size * 0.5;
4680
4733
  const sceneCenterY = size * 0.56;
4681
4734
  const bob = Math.sin(timeMs / 920 + animationPhase) * size * 0.014;
@@ -4772,12 +4825,12 @@ const octopus3dAvatarVisual = {
4772
4825
  x: -faceEyeSpacing,
4773
4826
  y: faceEyeYOffset,
4774
4827
  z: resolveEllipsoidSurfaceDepth(mantleRadiusX, mantleRadiusY, mantleRadiusZ, -faceEyeSpacing, faceEyeYOffset),
4775
- }, faceEyeRadiusX, faceEyeRadiusY, mantleCenter, headPitch, headYaw, sceneCenterX, sceneCenterY, size, palette, timeMs, animationPhase + eyeRandom() * 0.6, interaction, morphologyProfile.face.eyeStyle);
4828
+ }, faceEyeRadiusX, faceEyeRadiusY, mantleCenter, headPitch, headYaw, sceneCenterX, sceneCenterY, size, palette, timeMs, animationPhase + leftEyePhaseOffset, interaction, morphologyProfile.face.eyeStyle);
4776
4829
  drawProjectedOrganicEye(context, {
4777
4830
  x: faceEyeSpacing,
4778
4831
  y: faceEyeYOffset,
4779
4832
  z: resolveEllipsoidSurfaceDepth(mantleRadiusX, mantleRadiusY, mantleRadiusZ, faceEyeSpacing, faceEyeYOffset),
4780
- }, faceEyeRadiusX, faceEyeRadiusY, mantleCenter, headPitch, headYaw, sceneCenterX, sceneCenterY, size, palette, timeMs, animationPhase + 0.7 + eyeRandom() * 0.6, interaction, morphologyProfile.face.eyeStyle);
4833
+ }, faceEyeRadiusX, faceEyeRadiusY, mantleCenter, headPitch, headYaw, sceneCenterX, sceneCenterY, size, palette, timeMs, animationPhase + 0.7 + rightEyePhaseOffset, interaction, morphologyProfile.face.eyeStyle);
4781
4834
  drawProjectedOrganicMouth(context, [
4782
4835
  {
4783
4836
  x: -mouthHalfWidth,
@@ -4821,14 +4874,28 @@ function drawOctopus3dAtmosphere(context, size, palette, sceneCenterX, sceneCent
4821
4874
  /**
4822
4875
  * Draws the soft ground shadow below the octopus.
4823
4876
  *
4877
+ * Uses a scaled radial gradient instead of `context.filter = 'blur()'` to approximate the
4878
+ * blurry ellipse without triggering a costly software rasterization pass on every frame.
4879
+ *
4824
4880
  * @private helper of `octopus3dAvatarVisual`
4825
4881
  */
4826
4882
  function drawOctopus3dShadow(context, size, palette, interaction, timeMs) {
4883
+ const cx = size * 0.5 + interaction.gazeX * size * 0.04;
4884
+ const cy = size * 0.87 + Math.sin(timeMs / 920) * size * 0.008;
4885
+ const rx = size * (0.18 + interaction.intensity * 0.02);
4886
+ const ry = size * 0.06;
4827
4887
  context.save();
4828
- context.fillStyle = `${palette.shadow}66`;
4829
- context.filter = `blur(${size * 0.022}px)`;
4888
+ context.translate(cx, cy);
4889
+ context.scale(1, ry / rx);
4890
+ const blurRadius = rx * 1.4;
4891
+ const shadowGradient = context.createRadialGradient(0, 0, 0, 0, 0, blurRadius);
4892
+ shadowGradient.addColorStop(0, `${palette.shadow}7a`);
4893
+ shadowGradient.addColorStop(0.45, `${palette.shadow}44`);
4894
+ shadowGradient.addColorStop(0.8, `${palette.shadow}1a`);
4895
+ shadowGradient.addColorStop(1, `${palette.shadow}00`);
4896
+ context.fillStyle = shadowGradient;
4830
4897
  context.beginPath();
4831
- context.ellipse(size * 0.5 + interaction.gazeX * size * 0.04, size * 0.87 + Math.sin(timeMs / 920) * size * 0.008, size * (0.18 + interaction.intensity * 0.02), size * 0.06, 0, 0, Math.PI * 2);
4898
+ context.arc(0, 0, blurRadius, 0, Math.PI * 2);
4832
4899
  context.fill();
4833
4900
  context.restore();
4834
4901
  }
@@ -5059,6 +5126,35 @@ const LIGHT_DIRECTION$1 = normalizeVector3({
5059
5126
  y: -0.6,
5060
5127
  z: 0.98,
5061
5128
  });
5129
+ /**
5130
+ * Cache keyed by the `createRandom` factory reference (stable per mounted `<Avatar/>`).
5131
+ *
5132
+ * @private helper of `octopus3d2AvatarVisual`
5133
+ */
5134
+ const octopus3d2StableStateCache = new WeakMap();
5135
+ /**
5136
+ * Returns the stable per-avatar state, computing it on first access and caching for subsequent frames.
5137
+ *
5138
+ * @private helper of `octopus3d2AvatarVisual`
5139
+ */
5140
+ function getOctopus3d2StableState(createRandom) {
5141
+ const cached = octopus3d2StableStateCache.get(createRandom);
5142
+ if (cached !== undefined) {
5143
+ return cached;
5144
+ }
5145
+ const animationRandom = createRandom('octopus3d2-animation-profile');
5146
+ const eyeRandom = createRandom('octopus3d2-eye-profile');
5147
+ const leftEyePhaseOffset = eyeRandom() * 0.7;
5148
+ const rightEyePhaseOffset = eyeRandom() * 0.7;
5149
+ const state = {
5150
+ morphologyProfile: createOctopus3MorphologyProfile(createRandom),
5151
+ animationPhase: animationRandom() * Math.PI * 2,
5152
+ leftEyePhaseOffset,
5153
+ rightEyePhaseOffset,
5154
+ };
5155
+ octopus3d2StableStateCache.set(createRandom, state);
5156
+ return state;
5157
+ }
5062
5158
  /**
5063
5159
  * Octopus 3D 2 avatar visual.
5064
5160
  *
@@ -5071,10 +5167,7 @@ const octopus3d2AvatarVisual = {
5071
5167
  isAnimated: true,
5072
5168
  supportsPointerTracking: true,
5073
5169
  render({ context, size, palette, createRandom, timeMs, interaction }) {
5074
- const morphologyProfile = createOctopus3MorphologyProfile(createRandom);
5075
- const animationRandom = createRandom('octopus3d2-animation-profile');
5076
- const eyeRandom = createRandom('octopus3d2-eye-profile');
5077
- const animationPhase = animationRandom() * Math.PI * 2;
5170
+ const { morphologyProfile, animationPhase, leftEyePhaseOffset, rightEyePhaseOffset } = getOctopus3d2StableState(createRandom);
5078
5171
  const sceneCenterX = size * 0.5;
5079
5172
  const sceneCenterY = size * 0.575;
5080
5173
  const bob = Math.sin(timeMs / 940 + animationPhase) * size * 0.013;
@@ -5127,8 +5220,8 @@ const octopus3d2AvatarVisual = {
5127
5220
  const rightEyeLocalCenter = sampleBlobbyOctopusSurfacePoint(surfaceOptions, eyeLatitude, eyeLongitude);
5128
5221
  const eyeRadiusX = size * morphologyProfile.face.eyeRadiusXRatio * 0.78;
5129
5222
  const eyeRadiusY = eyeRadiusX * morphologyProfile.face.eyeHeightRatio * 0.92;
5130
- drawProjectedOrganicEye(context, leftEyeLocalCenter, eyeRadiusX, eyeRadiusY, meshCenter, rotationX, rotationY, sceneCenterX, sceneCenterY, size, palette, timeMs, animationPhase + eyeRandom() * 0.7, interaction, morphologyProfile.face.eyeStyle);
5131
- drawProjectedOrganicEye(context, rightEyeLocalCenter, eyeRadiusX, eyeRadiusY, meshCenter, rotationX, rotationY, sceneCenterX, sceneCenterY, size, palette, timeMs, animationPhase + 0.9 + eyeRandom() * 0.7, interaction, morphologyProfile.face.eyeStyle);
5223
+ drawProjectedOrganicEye(context, leftEyeLocalCenter, eyeRadiusX, eyeRadiusY, meshCenter, rotationX, rotationY, sceneCenterX, sceneCenterY, size, palette, timeMs, animationPhase + leftEyePhaseOffset, interaction, morphologyProfile.face.eyeStyle);
5224
+ drawProjectedOrganicEye(context, rightEyeLocalCenter, eyeRadiusX, eyeRadiusY, meshCenter, rotationX, rotationY, sceneCenterX, sceneCenterY, size, palette, timeMs, animationPhase + 0.9 + rightEyePhaseOffset, interaction, morphologyProfile.face.eyeStyle);
5132
5225
  drawProjectedOrganicMouth(context, [
5133
5226
  sampleBlobbyOctopusSurfacePoint(surfaceOptions, mouthLatitude, mouthCenterLongitude - mouthHalfLongitude),
5134
5227
  sampleBlobbyOctopusSurfacePoint(surfaceOptions, mouthCurveLatitude, mouthCenterLongitude),
@@ -5157,14 +5250,28 @@ function drawBlobbyOctopusAtmosphere(context, size, palette, sceneCenterX, scene
5157
5250
  /**
5158
5251
  * Draws the soft floor shadow that anchors the single mesh in the frame.
5159
5252
  *
5253
+ * Uses a scaled radial gradient instead of `context.filter = 'blur()'` to approximate the
5254
+ * blurry ellipse without triggering a costly software rasterization pass on every frame.
5255
+ *
5160
5256
  * @private helper of `octopus3d2AvatarVisual`
5161
5257
  */
5162
5258
  function drawBlobbyOctopusShadow(context, size, palette, interaction, timeMs, morphologyProfile) {
5259
+ const cx = size * 0.5 + interaction.gazeX * size * 0.045;
5260
+ const cy = size * 0.88 + Math.sin(timeMs / 940) * size * 0.008;
5261
+ const rx = size * (0.18 + (morphologyProfile.body.horizontalStretch - 1) * 0.04 + interaction.intensity * 0.018);
5262
+ const ry = size * 0.062;
5163
5263
  context.save();
5164
- context.fillStyle = `${palette.shadow}66`;
5165
- context.filter = `blur(${size * 0.024}px)`;
5264
+ context.translate(cx, cy);
5265
+ context.scale(1, ry / rx);
5266
+ const blurRadius = rx * 1.4;
5267
+ const shadowGradient = context.createRadialGradient(0, 0, 0, 0, 0, blurRadius);
5268
+ shadowGradient.addColorStop(0, `${palette.shadow}7a`);
5269
+ shadowGradient.addColorStop(0.45, `${palette.shadow}44`);
5270
+ shadowGradient.addColorStop(0.8, `${palette.shadow}1a`);
5271
+ shadowGradient.addColorStop(1, `${palette.shadow}00`);
5272
+ context.fillStyle = shadowGradient;
5166
5273
  context.beginPath();
5167
- context.ellipse(size * 0.5 + interaction.gazeX * size * 0.045, size * 0.88 + Math.sin(timeMs / 940) * size * 0.008, size * (0.18 + (morphologyProfile.body.horizontalStretch - 1) * 0.04 + interaction.intensity * 0.018), size * 0.062, 0, 0, Math.PI * 2);
5274
+ context.arc(0, 0, blurRadius, 0, Math.PI * 2);
5168
5275
  context.fill();
5169
5276
  context.restore();
5170
5277
  }
@@ -5320,6 +5427,40 @@ const LIGHT_DIRECTION = normalizeVector3({
5320
5427
  * @private helper of `octopus3d3AvatarVisual`
5321
5428
  */
5322
5429
  const OCTOPUS_TENTACLE_COUNT = 8;
5430
+ /**
5431
+ * Cache keyed by the `createRandom` factory reference, which is stable for the lifetime of one
5432
+ * mounted `<Avatar/>` component (created inside `resolveAvatarRenderDefinition` and held in a
5433
+ * React `useMemo`). Using a `WeakMap` ensures the entry is collected when the component unmounts.
5434
+ *
5435
+ * @private helper of `octopus3d3AvatarVisual`
5436
+ */
5437
+ const stableStateCache = new WeakMap();
5438
+ /**
5439
+ * Returns the stable per-avatar state, computing it on first access and returning the cached
5440
+ * result on every subsequent call within the same `<Avatar/>` mount.
5441
+ *
5442
+ * @private helper of `octopus3d3AvatarVisual`
5443
+ */
5444
+ function getOctopus3d3StableState(createRandom) {
5445
+ const cached = stableStateCache.get(createRandom);
5446
+ if (cached !== undefined) {
5447
+ return cached;
5448
+ }
5449
+ const morphologyProfile = createOctopus3MorphologyProfile(createRandom);
5450
+ const animationRandom = createRandom('octopus3d3-animation-profile');
5451
+ const eyeRandom = createRandom('octopus3d3-eye-profile');
5452
+ const leftEyePhaseOffset = eyeRandom() * 0.7;
5453
+ const rightEyePhaseOffset = eyeRandom() * 0.7;
5454
+ const state = {
5455
+ morphologyProfile,
5456
+ animationPhase: animationRandom() * Math.PI * 2,
5457
+ leftEyePhaseOffset,
5458
+ rightEyePhaseOffset,
5459
+ tentacleProfiles: createContinuousTentacleProfiles(createRandom, morphologyProfile),
5460
+ };
5461
+ stableStateCache.set(createRandom, state);
5462
+ return state;
5463
+ }
5323
5464
  /**
5324
5465
  * Octopus 3D 3 avatar visual.
5325
5466
  *
@@ -5332,11 +5473,7 @@ const octopus3d3AvatarVisual = {
5332
5473
  isAnimated: true,
5333
5474
  supportsPointerTracking: true,
5334
5475
  render({ context, size, palette, createRandom, timeMs, interaction }) {
5335
- const morphologyProfile = createOctopus3MorphologyProfile(createRandom);
5336
- const animationRandom = createRandom('octopus3d3-animation-profile');
5337
- const eyeRandom = createRandom('octopus3d3-eye-profile');
5338
- const animationPhase = animationRandom() * Math.PI * 2;
5339
- const tentacleProfiles = createContinuousTentacleProfiles(createRandom, morphologyProfile);
5476
+ const { morphologyProfile, animationPhase, leftEyePhaseOffset, rightEyePhaseOffset, tentacleProfiles } = getOctopus3d3StableState(createRandom);
5340
5477
  const sceneCenterX = size * 0.5;
5341
5478
  const sceneCenterY = size * 0.535;
5342
5479
  const bob = Math.sin(timeMs / 960 + animationPhase) * size * 0.012;
@@ -5413,8 +5550,8 @@ const octopus3d3AvatarVisual = {
5413
5550
  size,
5414
5551
  palette,
5415
5552
  });
5416
- drawProjectedOrganicEye(context, sampleContinuousOctopusSurfacePoint(surfaceOptions, eyeLatitude, -eyeLongitude), eyeRadiusX, eyeRadiusY, meshCenter, rotationX, rotationY, sceneCenterX, sceneCenterY, size, palette, timeMs, animationPhase + eyeRandom() * 0.7, interaction, morphologyProfile.face.eyeStyle);
5417
- drawProjectedOrganicEye(context, sampleContinuousOctopusSurfacePoint(surfaceOptions, eyeLatitude, eyeLongitude), eyeRadiusX, eyeRadiusY, meshCenter, rotationX, rotationY, sceneCenterX, sceneCenterY, size, palette, timeMs, animationPhase + 0.85 + eyeRandom() * 0.7, interaction, morphologyProfile.face.eyeStyle);
5553
+ drawProjectedOrganicEye(context, sampleContinuousOctopusSurfacePoint(surfaceOptions, eyeLatitude, -eyeLongitude), eyeRadiusX, eyeRadiusY, meshCenter, rotationX, rotationY, sceneCenterX, sceneCenterY, size, palette, timeMs, animationPhase + leftEyePhaseOffset, interaction, morphologyProfile.face.eyeStyle);
5554
+ drawProjectedOrganicEye(context, sampleContinuousOctopusSurfacePoint(surfaceOptions, eyeLatitude, eyeLongitude), eyeRadiusX, eyeRadiusY, meshCenter, rotationX, rotationY, sceneCenterX, sceneCenterY, size, palette, timeMs, animationPhase + 0.85 + rightEyePhaseOffset, interaction, morphologyProfile.face.eyeStyle);
5418
5555
  drawProjectedOrganicMouth(context, [
5419
5556
  sampleContinuousOctopusSurfacePoint(surfaceOptions, mouthLatitude, mouthCenterLongitude - mouthHalfLongitude),
5420
5557
  sampleContinuousOctopusSurfacePoint(surfaceOptions, mouthCurveLatitude, mouthCenterLongitude),
@@ -5465,14 +5602,30 @@ function drawContinuousOctopusAtmosphere(context, size, palette, sceneCenterX, s
5465
5602
  /**
5466
5603
  * Draws the soft lower shadow that anchors the octopus in the avatar frame.
5467
5604
  *
5605
+ * Uses a scaled radial gradient instead of `context.filter = 'blur()'` to approximate the
5606
+ * blurry ellipse without triggering a costly software rasterization pass on every frame.
5607
+ *
5468
5608
  * @private helper of `octopus3d3AvatarVisual`
5469
5609
  */
5470
5610
  function drawContinuousOctopusShadow(context, size, palette, interaction, timeMs, morphologyProfile) {
5611
+ const cx = size * 0.5 + interaction.gazeX * size * 0.045;
5612
+ const cy = size * 0.9 + Math.sin(timeMs / 980) * size * 0.007;
5613
+ const rx = size * (0.19 + morphologyProfile.tentacles.rootSpreadScale * 0.022 + interaction.intensity * 0.02);
5614
+ const ry = size * 0.06;
5615
+ // Scale the context so that drawing a circle produces the correct ellipse aspect ratio,
5616
+ // then fill with a radial gradient that approximates the blurry edge without context.filter.
5471
5617
  context.save();
5472
- context.fillStyle = `${palette.shadow}66`;
5473
- context.filter = `blur(${size * 0.025}px)`;
5618
+ context.translate(cx, cy);
5619
+ context.scale(1, ry / rx);
5620
+ const blurRadius = rx * 1.4;
5621
+ const shadowGradient = context.createRadialGradient(0, 0, 0, 0, 0, blurRadius);
5622
+ shadowGradient.addColorStop(0, `${palette.shadow}7a`);
5623
+ shadowGradient.addColorStop(0.45, `${palette.shadow}44`);
5624
+ shadowGradient.addColorStop(0.8, `${palette.shadow}1a`);
5625
+ shadowGradient.addColorStop(1, `${palette.shadow}00`);
5626
+ context.fillStyle = shadowGradient;
5474
5627
  context.beginPath();
5475
- context.ellipse(size * 0.5 + interaction.gazeX * size * 0.045, size * 0.9 + Math.sin(timeMs / 980) * size * 0.007, size * (0.19 + morphologyProfile.tentacles.rootSpreadScale * 0.022 + interaction.intensity * 0.02), size * 0.06, 0, 0, Math.PI * 2);
5628
+ context.arc(0, 0, blurRadius, 0, Math.PI * 2);
5476
5629
  context.fill();
5477
5630
  context.restore();
5478
5631
  }
@@ -30739,6 +30892,22 @@ function ChatInputArea(props) {
30739
30892
  }
30740
30893
 
30741
30894
  // Note: [💞] Ignore a discrepancy between file name and entity name
30895
+ /**
30896
+ * Target frames per second for the shared avatar animation loop.
30897
+ *
30898
+ * Animated octopus visuals change slowly enough that 24 fps is indistinguishable
30899
+ * from 60 fps in practice, while cutting rendering work by ~60% when multiple
30900
+ * avatars are on screen simultaneously.
30901
+ *
30902
+ * @private utility of the avatar rendering system
30903
+ */
30904
+ const AVATAR_TARGET_FPS = 24;
30905
+ /**
30906
+ * Minimum elapsed time in milliseconds required between avatar render passes.
30907
+ *
30908
+ * @private utility of the avatar rendering system
30909
+ */
30910
+ const AVATAR_TARGET_FRAME_INTERVAL_MS = 1000 / AVATAR_TARGET_FPS;
30742
30911
  /**
30743
30912
  * Next registration id used by the shared avatar animation scheduler.
30744
30913
  *
@@ -30757,6 +30926,14 @@ const avatarAnimationListeners = new Map();
30757
30926
  * @private utility of the avatar rendering system
30758
30927
  */
30759
30928
  let avatarAnimationFrameId = null;
30929
+ /**
30930
+ * Timestamp of the most recently rendered avatar frame.
30931
+ *
30932
+ * Used to throttle callbacks to `AVATAR_TARGET_FRAME_INTERVAL_MS`.
30933
+ *
30934
+ * @private utility of the avatar rendering system
30935
+ */
30936
+ let lastAvatarFrameTime = 0;
30760
30937
  /**
30761
30938
  * Registers one avatar animation callback in the shared animation loop.
30762
30939
  *
@@ -30791,8 +30968,11 @@ function ensureAvatarAnimationLoop() {
30791
30968
  }
30792
30969
  const runFrame = (now) => {
30793
30970
  avatarAnimationFrameId = null;
30794
- for (const avatarAnimationListener of [...avatarAnimationListeners.values()]) {
30795
- avatarAnimationListener(now);
30971
+ if (now - lastAvatarFrameTime >= AVATAR_TARGET_FRAME_INTERVAL_MS) {
30972
+ lastAvatarFrameTime = now;
30973
+ for (const avatarAnimationListener of [...avatarAnimationListeners.values()]) {
30974
+ avatarAnimationListener(now);
30975
+ }
30796
30976
  }
30797
30977
  ensureAvatarAnimationLoop();
30798
30978
  };