canvas-水墨
5 models · 百模竞速 · TOKRACE · share snapshot
Share and embed
Post to social channels, or use Markdown and badges for GitHub/README.
[](/en/r/U3INIV3X0M)
写一个单文件 HTML(只输出完整代码,不要解释),用原生 Canvas 实现一滴黑色墨水滴入一杯清水后扩散的动画。墨水入水瞬间应向外绽放出湍流、翻涌的羽状墨团——分形的卷须不断弯曲、分叉地铺开,并缓慢消散成淡淡的云雾。要用真实的流体运动(平流与扩散),而不是简单放大的圆。墨水要有密度变化:深而浓的内核、丝缕状半透明的边缘。顶部柔和打光。60fps,不要使用外部库。
Rerun this testFull rerun carries title, notes, Prompt and models; Prompt rerun only reuses the task text.
Visible
🏁 Live racereasoning / outputspeed t/s · ref 300
Kimi 月之暗面-high-think-budget
🏁
28672
⚡234 t/s
智谱 GLM-5.1
🏁
17392
—
智谱 GLM-5.2
🏁
16788
—
阶跃星辰
🏁
14211
133 t/s
Kimi 月之暗面
🏁
8192
—
🏁 Verdict
🏆 Fastest
Kimi 月之暗面-high-think-budget
234 tok/s
💰 Cheapest
阶跃星辰
≈$0.016
⭐ Recommended
阶跃星辰
cheapest
阶跃星辰
step-3.7-flash
用户想要一个单文件的HTML,使用原生Canvas实现墨水滴入清水后的扩散动画。要求:
1. 黑色墨水滴入清水
2. 向外绽放出湍流、翻涌的羽状墨团
3. 分形的卷须不断弯曲、分叉地铺开
4. 缓慢消散成淡淡的云雾
5. 真实的流体运动(平流与扩散),不是简单放大的圆
6. 墨水密度变化:深而浓的内核、丝缕状半透明的边缘
7. 顶部柔和打光
8. 60fps
9. 不要使用外部库
我需要实现一个粒子系统来模拟墨水扩散。为了获得真实的流体运动,我需要:
- 使用速度场来模拟平流
- 使用扩散方程
- 分形结构:使用递归或噪声来创建分形卷须
让我设计一个基于粒子的系统:
1. 墨滴最初集中在一点
2. 粒子受速度场驱动
3. 速度场使用噪声(Perlin-like)来创建湍流效果
4. 粒子有生命周期,随着时间扩散和消散
5. 使用多个粒子层来表现密度变化
我将使用简化版的Simplex/Perlin噪声来生成速度场,并用粒子系统来模拟墨水。
让我创建一个完整的实现:
- 使用多个粒子对象
- 粒子具有位置、速度、生命期、密度属性
- 速度场使用多层噪声创建湍流
- 墨水滴入瞬间有爆发效果
- 分形卷须:使用递归细分或分支粒子
实际上,为了获得分形卷须效果,我可以:
- 让一部分粒子沿噪声场流动,形成卷须
- 当粒子生命期过半时,让它们分叉出新的粒子
- 使用贝塞尔曲线或直接粒子来绘制
让我简化设计但保持视觉效果:
1. 主粒子系统:数百个粒子跟随噪声场流动
2. 每个粒子有密度值(不透明度),随时间衰减
3. 粒子从中心爆发,向外扩散
4. 速度场由多层正弦/余弦组合的伪噪声生成
5. 密度较高的粒子形成内核,低密度的形成边缘羽丝
6. 使用径向渐变和半透明来绘制
关于"顶部柔和打光",我可以在粒子的渲染中使用径向渐变,让中心更亮,或者整体有一个从上往下的光照效果。墨水本身是黑色的,但打光可以表现为边缘的高光——实际上对于黑色墨水,打光可能意味着顶部有微妙的亮边,就像墨水被光照到边缘时会显示微光。我可以通过在粒子上方叠加一层微妙的高光来实现,或者让墨水的边缘在某些角度显示为深灰色而非纯黑。
让我重新考虑:墨水是黑色的,顶部打光可能意味着墨水表面会有一些光泽,或者墨水边缘在光照下呈现深灰色。在2D模拟中,我可以在粒子的渲染中,让靠近顶部(或光源方向)的粒子边缘稍微亮一些,或者使用渐变来模拟光照效果。
实际上,对于黑色墨水在水中,打光效果可能表现为:
- 墨水团边缘有微妙的亮度变化
- 墨水中有一些光线散射的亮斑
- 或者更简单:在渲染时对粒子使用径向渐变,使其中心不透明(深黑),边缘半透明(深灰),这本身就模拟了立体感和光照
让我专注于实现一个高质量的粒子模拟:
**技术方案:**
- 800-1200个粒子
- 使用Simplex-like噪声(3层正弦波组合)创建2D速度场
- 每个粒子:x, y, vx, vy, life, maxLife, density, size
- 初始爆发:粒子从中心点以随机速度向外射出
- 速度场随时间缓慢变化
- 粒子在速度场中平流
- 扩散:粒子轻微随机游走
- 密度衰减:粒子逐渐变透明
- 分叉:当粒子生命期到达一定比例时,有一定概率产生子粒子
**噪声函数:**
我需要一个简单的2D噪声。我将使用多层正弦波组合来创建伪噪声:
```
noise(x, y, t) = sum of sin(x*f1 + t) * cos(y*f1 + t) * sin((x+y)*f2 + t) ...
```
**渲染:**
- 使用Canvas的globalCompositeOperation或直接绘制
- 粒子用径向渐变绘制(中心浓黑,边缘半透明)
- 整体有一个微妙的顶部光照——我可以通过在粒子的径向渐变中让上半部分稍亮来实现
- 或者使用多个不同大小的粒子叠加
让我开始编写代码。我需要特别注意性能,因为要在60fps下运行。
实际上,让我重新设计:
**核心机制:**
1. 墨滴中心发射器,在最初几秒内持续发射粒子
2. 粒子沿噪声场流动
3. 噪声场模拟水流(有大的环流和小的湍流)
4. 粒子有密度属性,影响其大小和透明度
5. 内核粒子(高密度)较大较暗
6. 丝缕边缘粒子(低密度)较小较透明
7. 生命周期管理
**分形卷须:**
为了实现分形卷须,当粒子存活到一定阶段,它们可以:
- 在特定条件下分裂成2-3个粒子
- 分裂的粒子沿略微不同的方向移动
- 这创造了分叉效果
让我也加入一些"卷须"结构——粒子不是完全随机的,而是倾向于沿某些路径流动。噪声场本身会创造这种效果,尤其是如果噪声有某种结构的话。
**光照:**
顶部柔和打光——我可以在绘制粒子时使用径向渐变,使粒子的上半部分(朝向顶部光源)有微妙的高光。对于黑色墨水,高光会是深灰色,模拟光线从顶部照射在墨水表面的效果。我可以通过在径向渐变中使用不同的透明度来实现——顶部(光源方向)的粒子边缘稍微亮一些。
或者更简单的做法:使用两个径向渐变叠加——一个深黑色的,一个稍亮的在上方偏移的位置,模拟顶部光照。
让我简化光照:每个粒子绘制时使用径向渐变,中心是rgba(0,0,0,density),边缘是rgba(20,20,30,density*0.3)。这样边缘就不会是纯黑的,而是带有一些深蓝色的微光,模拟光线在墨水边缘的散射。
实际上对于"顶部柔和打光",我理解为整体场景有一个来自顶部/左上方的柔和光源。在黑色墨水中,这意味着:
- 墨水团的顶部/左侧边缘会有一些高光
- 这可以通过给粒子在绘制时添加微妙的偏移来模拟
我可以在绘制时,为每个粒子添加一个略微向上偏移的小光点(半透明白色或浅灰),模拟光照反射。
让我开始编写完整代码。
**粒子属性:**
```
particles = [
{
x, y,
vx, vy,
life, maxLife,
density, // 0-1, 初始密度
size,
generation, // 分叉代数
parentAngle, // 继承的方向
}
]
```
**发射器:**
- 前3秒内,每帧发射5-10个新粒子
- 粒子从中心以随机角度射出
- 初始速度有径向分量和切向分量
- 初始密度随机(模拟不同浓度的墨水)
**噪声场:**
使用多层正弦波组合创建2D噪声。我还会加入一些涡旋结构。
让我也加入一些"大的卷须"——少数大粒子(高密度)作为主卷须,它们携带更多的密度,移动更慢,并且会分裂。
实际上,让我重新组织一下。为了获得最好的视觉效果,我将使用:
1. **主卷须系统**:约50-100个高密度粒子,形成可见的卷须
2. **雾气系统**:约500-800个低密度粒子,形成消散的云雾
3. 卷须粒子有更强的分叉行为
4. 雾气粒子更随机地扩散
这样就能同时展现"分形的卷须不断弯曲、分叉地铺开"和"缓慢消散成淡淡的云雾"。
让我开始编写完整的HTML文件。
我会特别注意:
- 粒子数量控制在合理范围以保证性能
- 使用requestAnimationFrame
- 60fps目标
- 响应式画布
另外关于"顶部柔和打光",我想到一个更好的实现方式:在整个画布上使用一个从上到下的微妙的径向渐变叠加。或者,更巧妙地,在粒子渲染中使用颜色变化——墨水的顶部边缘在光照下呈现为深蓝灰色而非纯黑。我可以在粒子的径向渐变中,让上半部分(朝向画布顶部)的透明度稍低,颜色稍亮。
对于黑色墨水,打光效果可以这样表现:
- 墨水的核心是纯黑(rgba(5,5,5, density))
- 墨水的表面(尤其是顶部)有微妙的亮度——在光照下,墨水表面的弯曲会捕捉到一些光
- 我可以在绘制粒子时,使用一个椭圆形的渐变,其中上部稍亮
让我在粒子绘制函数中实现这一点。
好的,让我写代码。我会特别注意分形卷须的实现。为了实现分叉,我使用以下策略:
- 高密度粒子(generation 0)移动较慢
- 当它们生命期到达30-50%时,有概率分裂成2个新粒子
- 新粒子(generation +1)密度减半,大小减半,生命期也相应缩短
- 子粒子沿略微不同的方向移动
- 继续分裂直到generation达到3-4层
这样就能创造出分形结构。
同时,低密度的雾气粒子(单独的发射器生成)更随机地扩散,形成云雾效果。
另外,我还需要处理"墨水入水瞬间"的效果。初始几帧应该有明显的爆发——粒子以较高的初始速度射出,然后迅速减速并被湍流带走。
让我也加入一些涡旋——在速度场中使用一些预设的涡旋中心,粒子会被涡旋吸引或排斥,创造出翻涌的效果。
好,现在开始编写代码。我会写一个自包含的HTML文件。
关于性能:我预计同时存在的粒子数量在500-1000之间。在每帧中,我需要:
1. 更新所有粒子的位置(应用速度场)
2. 处理分叉
3. 移除死亡粒子
4. 渲染所有粒子
这应该在60fps下运行良好。
让我也考虑使用离屏canvas来优化渲染,但由于粒子数量不算特别多,直接在主canvas上渲染应该可以。
对于噪声函数,我将实现一个简单的基于正弦波的伪噪声,足够创建有机的流动模式。
另外,我注意到用户提到"平流与扩散"。平流(advection)是粒子随速度场移动,扩散(diffusion)是粒子之间的混合和随机游走。我可以通过在速度场中加入轻微的随机游走来模拟扩散。
好的,让我开始写完整代码。
还有一个重要的事情:墨水入水瞬间。我会让初始的爆发非常明显——第一批粒子以高速射出,并且有较大的初始密度。这些粒子会形成第一波扩散。然后后续的粒子逐渐减弱。
关于"缓慢消散成淡淡的云雾",我需要让粒子在生命期结束时不仅消失,而且留下痕迹。一种做法是让低密度粒子在生命期末期变得非常透明,逐渐融入背景。另一种是让粒子在生命期结束时分裂成更小的碎片。我选择让粒子在生命期超过一半后开始快速衰减其密度,模拟墨水被稀释。
另外,为了让墨水的扩散看起来更真实,我可以在粒子之间加入一些"连接"——但粒子数量太多时这会太昂贵。相反,我依靠大量半透明粒子叠加来创建连续的墨水形态。
对于密度较高的区域,粒子会自然重叠,产生更深的颜色。对于丝缕状边缘,粒子较少且密度较低,呈现半透明。
这实际上模拟了真实的墨水扩散——中心密度高,边缘密度低。
好的,现在写代码。
我还想加入一些微妙的颜色——虽然是黑色墨水,但水的折射和光照可以给墨水带来微妙的深蓝或深紫色调。我可以在粒子的颜色中使用极深的蓝灰色,比如rgb(5, 5, 15)或类似,而不是纯黑。这样在光照下会更有层次感。
对于"顶部柔和打光",我可以让粒子在渲染时,如果它们位于画布的上半部分,就稍微亮一些(比如密度衰减慢一些,或者颜色稍亮)。这模拟了光源从上方照射的效果。
另外,我可以在整个画布上添加一个微妙的顶部光照效果——在画布上方有一个非常淡的白色到透明的渐变叠加,模拟光线从上方照射。
这些细节会让整体效果更加真实。
让我也考虑:墨水扩散后,周围的水应该看起来略微被墨水染色。对于纯黑色墨水,这意味着在墨水边缘,水会呈现非常淡的灰色。我可以通过绘制一些非常大且非常透明的粒子来模拟这一点。
好的,我觉得设计已经足够详细了,开始编写代码。
另外,关于canvas的大小,我会使用窗口大小,并响应窗口调整。
让我也加入一些微妙的背景纹理或噪声,模拟水中的微小颗粒——不过这可能过度复杂。保持背景为接近白色的淡色(模拟清水),让墨水成为唯一的焦点。
关于"羽状墨团",我理解为墨水扩散时像羽毛一样向外延伸的形状。这可以通过让高密度粒子沿着噪声场中特定的流动线移动来实现——噪声场本身就能创造这种有机的形状。
对于"翻涌"效果,我可以在速度场中使用一些周期性的变化,模拟水流的脉动。
好的,开始写代码。我会特别注意以下细节:
1. 粒子发射模式模拟墨滴入水
2. 速度场创建湍流
3. 分叉机制创造分形卷须
4. 密度衰减创造消散效果
5. 光照通过粒子渲染实现
另外,我想加入一些"卷须"的可见性——它们应该像手指一样从中心延伸出去。为了实现这一点,我可以让高密度粒子倾向于沿着噪声场中的"脊线"移动。我可以通过检测噪声场的梯度来实现这一点——粒子会沿着梯度最大的方向移动(类似于 streamline)。
实际上,一个更简单且效果更好的方法是:使用噪声场来驱动粒子,但让粒子的初始速度带有一定的方向偏好。当粒子从中心爆发时,它们以不同角度射出,然后被噪声场弯曲。高密度粒子(生成级别0)移动更慢,更容易被噪声场弯曲成卷须形状。低密度粒子移动更快,更分散。
让我也加入一些"涡旋"——在速度场中放置几个移动的涡旋中心。粒子会被涡旋吸引,创造出翻涌的效果。
好的,代码会很长。让我开始组织。
实际上关于"顶部柔和打光",我想到一种很好的实现方式:在渲染粒子时,每个粒子使用径向渐变。渐变从中心的不透明深色,到边缘的透明。但对于光照,我可以偏移渐变中心——让渐变中心略微向上(朝向光源),这样粒子的底部(远离光源)会有更多的墨水聚集(更不透明),而顶部(朝向光源)则较亮。这种微妙的偏移可以模拟3D墨水团上的光照。
对于单个粒子来说,偏移渐变中心可能不太明显。但对于整体效果,如果有很多粒子叠加,这种效果会累积。我可以:
- 使用略微偏移的径向渐变
- 渐变中心比粒子几何中心高约20-30%
- 这样粒子下方(远离光源)看起来更浓密,上方(靠近光源)更亮
这个效果对于整体墨水团的外观会非常有效。
好的,开始写代码。我会特别注意粒子系统的设计和噪声函数的实现。
另外,关于墨水的"滴入"动作,我想在动画开始时有一个明显的滴落效果。粒子最初集中在画布顶部中心的一个小点,然后"滴落"到画布中央(或画布中下部),然后爆发扩散。或者更简单:墨滴从顶部滴入,在画布中央形成墨水团,然后向外扩散。
让我把墨滴初始位置设在画布中央偏上,模拟墨滴从上方落入水中。
实际上,用户说"一滴黑色墨水滴入一杯清水",所以:
- 初始状态:清水(白色/淡色背景)
- 墨滴从上方滴入(可以有一个小的入水动画)
- 墨水在入水点开始扩散
我会在画布中央设置初始爆发点。为了让动画更有趣,我可以让墨滴从顶部中央落下,在画布中央入水,然后开始扩散。
关于"入水瞬间",我可以在开始时显示墨滴从上方落下,到达水面后产生爆发。为了简单,我可以让墨滴初始位置在画布中央上方,前0.5秒快速下落,然后在中央爆发。
或者更简单:动画开始时,墨滴已经在中央,立即开始扩散。扩散的初始阶段就是"入水瞬间"——粒子以高速向外射出,模拟入水的冲击力。
让我设计动画阶段:
1. 0-0.5秒:墨滴入水,粒子高速爆发
2. 0.5-3秒:墨水快速扩散,形成大的墨团,卷须开始分叉
3. 3-8秒:墨水继续扩散,卷须进一步分叉,开始消散
4. 8秒以上:墨水逐渐消散成云雾
整个动画大约8-10秒完成一次完整的扩散-消散周期。
我还需要持续发射一些粒子以保持墨水团的形态。在扩散初期发射率较高,后期降低。
好的,我已经想得足够多了。开始写代码。
我会特别注意代码结构清晰,并且确保动画流畅。
关于粒子渲染的一个优化:如果粒子数量很多,使用径向渐变可能会比较慢。我可以预渲染几种不同大小和密度的粒子到离屏canvas,然后用drawImage来绘制。这是一个常见的优化技术。
让我预渲染大约20种不同大小的粒子(直径从2px到30px),每种有不同的密度/透明度。这样在渲染时可以直接drawImage,避免每帧创建新的渐变。
实际上,为了灵活性(光照偏移等),我可能还是需要在运行时创建渐变。但预渲染确实能提升性能。让我折中:预渲染基础粒子,然后在绘制时进行微调。
不,为了简单和效果,我直接在每帧使用arc和径向渐变。对于1000个粒子,这应该还好。现代浏览器可以处理。
实际上,更好的做法是使用globalCompositeOperation = 'source-over'并配合较低的alpha来绘制大量粒子,让它们自然叠加。这样密集区域自然变暗。
好的,开始写完整的HTML代码。我会写一个完整的、注释清晰的实现。
关于分形卷须的实现,让我再想想。一种方法是使用"递归粒子链":
- 每个高密度粒子在移动时,会在身后留下一串痕迹粒子
- 这些痕迹粒子形成卷须
- 卷须会分叉
另一种更简单的方法:
- 使用大量粒子在噪声场中流动
- 高密度粒子移动慢,自然形成更长的路径
- 分叉通过定时分裂实现
- 由于噪声场是连续的,粒子流会形成类似卷须的结构
我觉得第二种方法更实际,也能产生很好的效果。关键是噪声场需要有足够的结构来引导粒子形成卷须。
让我也加入一些"丝缕"效果——当粒子密度衰减到一定程度时,它们变得更加分散和丝缕状。我可以通过让低密度粒子的运动更受随机影响来实现。
好的,开始写代码。我会特别注意:
1. 噪声函数的参数调优
2. 粒子参数的平衡
3. 渲染效果的微调
还有一件事:关于"顶部柔和打光"的实现。我想到一个非常好的方法。在渲染完所有粒子后,在整个画布上叠加一个从顶部到底部的微妙渐变——顶部是半透明的暖白(模拟光),底部完全透明。这会给整个场景一个统一的顶部光照感。
更具体地,我可以在画布上使用一个线性渐变(从顶部半透明白色到底部完全透明),以非常低的alpha(比如0.03-0.05)叠加。这样:
- 墨水区域顶部会略微亮一些
- 底部保持不变
- 清水区域也会被微微照亮(模拟水面的反光)
这个叠加层可以使用Canvas的渐变来实现,非常高效。
另外,对于墨水本身,我还可以在粒子渲染时加入微妙的暖色调——不是纯黑,而是带有极深蓝或深棕的色调,模拟真实墨水在光照下的颜色。纯黑在数字渲染中可能看起来太"死",一些微妙的色调变化会让它看起来更有机。
好的,现在真的开始写代码了。
另外我还想加入一些微小的气泡或杂质,在墨滴入水时产生——这些小气泡会上升,增加入水的真实感。不过这可能增加复杂度。让我保持专注于主要的墨水扩散效果。
关于水杯的视觉——用户提到了"一杯清水",所以可能需要在背景中表现一些杯子的轮廓或至少水的边界。但为了简单,我可以让整个画布就是水,模拟俯视或侧视的杯子。如果模拟俯视,那么整个画布就是水面,墨滴从上方落入。如果模拟侧视,可以显示杯子的轮廓。
我觉得俯视水面是最自然的——整个画布是水面,墨滴从中央落入,向外扩散。这样最能展现墨水的扩散形态。
好的,开始编码。
另外还需要考虑:墨滴入水后,水本身可能会有轻微的涟漪。但涟漪可能分散注意力。让我专注于墨水本身。
关于粒子系统的具体参数,我需要在实现后调整。让我先设定一些初始值,然后可以微调。
**粒子属性:**
- 最大数量:约1200个
- 初始生命期:3-8秒(根据密度不同)
- 密度衰减:随时间线性或指数衰减
- 大小:根据密度从2px到20px不等
- 高密度粒子(核心):较大,较不透明,移动较慢
- 低密度粒子(边缘):较小,较透明,移动较快
**发
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>墨滴入水 — 流体扩散动画</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; }
canvas { display: block; width: 100%; height: 100%; cursor: default; }
</style>
</head>
<body>
<canvas id="inkCanvas"></canvas>
<script>
(function() {
'use strict';
// ─────────────────────────────────────
// 画布初始化
// ─────────────────────────────────────
const canvas = document.getElementById('inkCanvas');
const ctx = canvas.getContext('2d');
let W, H, centerX, centerY, scale;
function resize() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
centerX = W / 2;
centerY = H / 2;
scale = Math.min(W, H);
}
resize();
window.addEventListener('resize', () => {
resize();
// 重新预渲染粒子纹理
preRenderParticleTextures();
});
// ─────────────────────────────────────
// 预渲染粒子纹理(性能优化)
// ─────────────────────────────────────
const particleTextures = []; // 不同密度/大小的预渲染纹理
const TEX_SIZE = 128;
function createRadialGradientTexture(density, baseSize) {
const offCanvas = document.createElement('canvas');
offCanvas.width = TEX_SIZE;
offCanvas.height = TEX_SIZE;
const offCtx = offCanvas.getContext('2d');
const half = TEX_SIZE / 2;
const grad = offCtx.createRadialGradient(half, half * 0.92, 0, half, half, half);
// 顶部(光源方向)微亮,底部(远离光源)更暗——模拟顶部打光
const coreAlpha = Math.min(1, density * 1.4);
grad.addColorStop(0, `rgba(6,5,10,${coreAlpha})`);
grad.addColorStop(0.15, `rgba(12,10,18,${coreAlpha * 0.95})`);
grad.addColorStop(0.35, `rgba(20,18,30,${coreAlpha * 0.7})`);
grad.addColorStop(0.6, `rgba(35,30,48,${coreAlpha * 0.3})`);
grad.addColorStop(0.85, `rgba(50,45,65,${coreAlpha * 0.08})`);
grad.addColorStop(1, `rgba(60,55,75,0)`);
offCtx.fillStyle = grad;
offCtx.fillRect(0, 0, TEX_SIZE, TEX_SIZE);
return offCanvas;
}
function preRenderParticleTextures() {
particleTextures.length = 0;
// 4种密度级别 × 5种大小级别
const densityLevels = [0.9, 0.6, 0.35, 0.15];
const sizeRatios = [1.0, 0.7, 0.45, 0.25, 0.12];
for (const d of densityLevels) {
for (const s of sizeRatios) {
const tex = createRadialGradientTexture(d, s);
particleTextures.push({ canvas: tex, density: d, sizeRatio: s });
}
}
}
preRenderParticleTextures();
// 选择最接近的纹理
function selectTexture(density, sizeRatio) {
let best = particleTextures[0];
let bestDist = Infinity;
for (const pt of particleTextures) {
const dist = Math.abs(pt.density - density) * 10 + Math.abs(pt.sizeRatio - sizeRatio) * 5;
if (dist < bestDist) {
bestDist = dist;
best = pt;
}
}
return best;
}
// ─────────────────────────────────────
// 伪噪声函数(多层正弦波组合,模拟Perlin噪声)
// ─────────────────────────────────────
function noise2D(x, y, t) {
let val = 0;
val += Math.sin(x * 0.7 + t * 0.31) * Math.cos(y * 0.73 + t * 0.27) * 1.0;
val += Math.sin(x * 1.37 + y * 1.15 + t * 0.53) * 0.65;
val += Math.cos(x * 2.13 - t * 0.71) * Math.sin(y * 2.37 + t * 0.63) * 0.35;
val += Math.sin((x + y) * 3.71 + t * 0.97) * 0.18;
val += Math.cos(x * 5.1 + y * 4.9 - t * 1.3) * Math.sin(y * 5.3 - x * 4.7 + t * 1.1) * 0.1;
return val;
}
// 涡旋叠加:在指定位置创建环流
function vortexField(px, py, t, vortices) {
let vx = 0, vy = 0;
for (const v of vortices) {
const dx = px - v.x;
const dy = py - v.y;
const dist = Math.sqrt(dx * dx + dy * dy) + 0.001;
const strength = v.strength * Math.exp(-dist * dist / (v.radius * v.radius));
// 环流方向:垂直于径向
vx += (-dy / dist) * strength;
vy += (dx / dist) * strength;
}
return { vx, vy };
}
// ─────────────────────────────────────
// 粒子系统
// ─────────────────────────────────────
const MAX_PARTICLES = 1400;
const particles = [];
let particleIdCounter = 0;
// 涡旋定义(随时间缓慢移动)
const vortices = [
{ x: 0.35, y: 0.3, strength: 45, radius: 0.22, phase: 0 },
{ x: 0.65, y: 0.4, strength: -38, radius: 0.2, phase: 1.2 },
{ x: 0.5, y: 0.6, strength: 32, radius: 0.25, phase: 2.5 },
{ x: 0.3, y: 0.55, strength: -28, radius: 0.18, phase: 3.8 },
{ x: 0.7, y: 0.25, strength: 25, radius: 0.15, phase: 5.1 },
];
function updateVortices(t) {
for (const v of vortices) {
v.x += Math.sin(t * 0.15 + v.phase) * 0.0003;
v.y += Math.cos(t * 0.12 + v.phase * 1.3) * 0.0003;
v.x = Math.max(0.15, Math.min(0.85, v.x));
v.y = Math.max(0.15, Math.min(0.85, v.y));
}
}
function createParticle(x, y, vx, vy, density, maxLife, generation) {
if (particles.length >= MAX_PARTICLES) {
// 替换最老的粒子
let oldest = 0;
for (let i = 1; i < particles.length; i++) {
if (particles[i].life < particles[oldest].life) oldest = i;
}
particles[oldest].x = x;
particles[oldest].y = y;
particles[oldest].vx = vx;
particles[oldest].vy = vy;
particles[oldest].density = density;
particles[oldest].maxLife = maxLife;
particles[oldest].life = maxLife;
particles[oldest].generation = generation;
particles[oldest].id = particleIdCounter++;
return;
}
particles.push({
x, y, vx, vy,
density,
maxLife,
life: maxLife,
generation,
id: particleIdCounter++,
});
}
function spawnInitialBurst(t) {
// 墨滴入水瞬间的爆发
const burstCount = 35;
const burstSpeed = scale * 0.35;
for (let i = 0; i < burstCount; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = burstSpeed * (0.4 + Math.random() * 0.8);
const vx = Math.cos(angle) * speed + (Math.random() - 0.5) * scale * 0.06;
const vy = Math.sin(angle) * speed + (Math.random() - 0.5) * scale * 0.06;
const density = 0.7 + Math.random() * 0.3;
const maxLife = 3.5 + Math.random() * 4.5;
createParticle(centerX, centerY, vx, vy, density, maxLife, 0);
}
}
function spawnContinuous(t, elapsed) {
// 持续发射粒子,模拟墨水不断扩散
let rate;
if (elapsed < 1.5) rate = 12;
else if (elapsed < 4) rate = 6;
else if (elapsed < 7) rate = 3;
else rate = 1;
const count = Math.floor(rate * (0.8 + Math.random() * 0.4));
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const dist = Math.random() * scale * 0.06;
const px = centerX + Math.cos(angle) * dist;
const py = centerY + Math.sin(angle) * dist;
const speed = scale * 0.08 * (0.3 + Math.random() * 0.7);
const vx = Math.cos(angle) * speed + (Math.random() - 0.5) * scale * 0.02;
const vy = Math.sin(angle) * speed + (Math.random() - 0.5) * scale * 0.02;
const density = 0.25 + Math.random() * 0.5;
const maxLife = 4 + Math.random() * 5;
createParticle(px, py, vx, vy, density, maxLife, 0);
}
}
function spawnSplashBubbles(t) {
// 入水瞬间的小气泡(极少量)
if (t < 0.6 && Math.random() < 0.4) {
const angle = Math.random() * Math.PI * 2;
const dist = Math.random() * scale * 0.03;
const px = centerX + Math.cos(angle) * dist;
const py = centerY + Math.sin(angle) * dist;
const bubbleVx = (Math.random() - 0.5) * scale * 0.03;
const bubbleVy = -scale * (0.04 + Math.random() * 0.1); // 向上
createParticle(px, py, bubbleVx, bubbleVy, 0.08, 0.6 + Math.random() * 0.8, 99);
}
}
// ─────────────────────────────────────
// 物理更新
// ─────────────────────────────────────
function updateParticles(dt, t, elapsed) {
const invScale = 1 / scale;
const dtClamped = Math.min(dt, 0.05); // 防止大帧跳跃
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life -= dtClamped;
if (p.life <= 0) {
particles.splice(i, 1);
continue;
}
// 生命进度
const lifeRatio = p.life / p.maxLife;
// ── 速度场(噪声驱动平流) ──
const nx = p.x * invScale;
const ny = p.y * invScale;
const noiseVal = noise2D(nx * 2.5, ny * 2.5, t * 0.35);
const noiseAngle = noiseVal * Math.PI * 3.0;
const flowSpeed = scale * 0.18 * (1 + lifeRatio * 0.5);
const flowVx = Math.cos(noiseAngle) * flowSpeed;
const flowVy = Math.sin(noiseAngle) * flowSpeed;
// 涡旋贡献
const vort = vortexField(nx, ny, t, vortices);
const vortScale = scale * invScale;
const vortexVx = vort.vx * vortScale;
const vortexVy = vort.vy * vortScale;
// 初始爆发后,加入微弱的向心/扩散混合力
const dx = p.x - centerX;
const dy = p.y - centerY;
const distFromCenter = Math.sqrt(dx * dx + dy * dy) + 1;
const radialDirX = dx / distFromCenter;
const radialDirY = dy / distFromCenter;
// 初期有轻微向心(回流),后期扩散
const radialForce = elapsed < 2 ? -0.015 : 0.01;
const radialVx = radialDirX * radialForce * scale * dtClamped * 2;
const radialVy = radialDirY * radialForce * scale * dtClamped * 2;
// 混合速度
const damping = p.generation === 99 ? 1 : (0.3 + lifeRatio * 0.7); // 气泡几乎不受阻尼
p.vx = p.vx * (1 - 3.5 * dtClamped) + (flowVx + vortexVx + radialVx) * dtClamped * 8;
p.vy = p.vy * (1 - 3.5 * dtClamped) + (flowVy + vortexVy + radialVy) * dtClamped * 8;
// 扩散:随机游走
const diffusionStrength = scale * 0.04 * dtClamped / (lifeRatio + 0.3);
p.vx += (Math.random() - 0.5) * diffusionStrength * 2;
p.vy += (Math.random() - 0.5) * diffusionStrength * 2;
// 速度限制
const maxSpeed = scale * 0.5;
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
if (speed > maxSpeed) {
const s = maxSpeed / speed;
p.vx *= s;
p.vy *= s;
}
// 更新位置
p.x += p.vx * dtClamped;
p.y += p.vy * dtClamped;
// ── 密度衰减(墨水稀释消散) ──
const decayRate = 0.08 + p.generation * 0.06; // 高代数粒子衰减更快
p.density -= decayRate * dtClamped;
if (p.density < 0.02) p.density = 0.02;
// ── 分叉逻辑(仅高密度主卷须粒子) ──
if (p.generation < 4 && p.generation !== 99 && lifeRatio < 0.45 && lifeRatio > 0.12) {
const branchProb = 0.012 * (1 - p.generation * 0.3) * dtClamped;
if (Math.random() < branchProb && particles.length < MAX_PARTICLES - 20) {
const branchAngle = (Math.random() - 0.5) * 0.7 + Math.atan2(p.vy, p.vx);
const branchSpeed = Math.sqrt(p.vx * p.vx + p.vy * p.vy) * (0.6 + Math.random() * 0.5);
const bvx = Math.cos(branchAngle) * branchSpeed;
const bvy = Math.sin(branchAngle) * branchSpeed;
const childDensity = p.density * (0.4 + Math.random() * 0.35);
const childLife = p.maxLife * (0.4 + Math.random() * 0.4);
createParticle(p.x, p.y, bvx, bvy, childDensity, childLife, p.generation + 1);
// 父粒子密度略微降低
p.density *= 0.7;
}
}
}
}
// ─────────────────────────────────────
// 渲染
// ─────────────────────────────────────
function render() {
// 清屏:清水背景
ctx.fillStyle = '#f2f4f7';
ctx.fillRect(0, 0, W, H);
// 添加非常微妙的背景纹理(模拟水的微小折射)
const bgGrad = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, scale * 0.7);
bgGrad.addColorStop(0, 'rgba(245,248,252,0.3)');
bgGrad.addColorStop(1, 'rgba(230,235,242,0.1)');
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, W, H);
// 按密度排序(低密度先画,高密度后画,确保高密度在上面)
particles.sort((a, b) => a.density - b.density);
// 绘制粒子
for (const p of particles) {
const lifeRatio = p.life / p.maxLife;
const currentDensity = p.density * Math.min(1, lifeRatio * 3); // 后期加速衰减
const sizeRatio = 0.12 + currentDensity * 0.88; // 0.12 ~ 1.0
const particleSize = scale * 0.018 * sizeRatio;
const tex = selectTexture(currentDensity, sizeRatio);
ctx.globalAlpha = Math.min(1, currentDensity * (0.5 + lifeRatio * 0.5));
ctx.drawImage(
tex.canvas,
p.x - particleSize / 2,
p.y - particleSize / 2,
particleSize,
particleSize
);
}
ctx.globalAlpha = 1;
// ── 顶部柔和打光(径向光晕) ──
const lightGrad = ctx.createRadialGradient(
centerX, -H * 0.15, 0,
centerX, -H * 0.15, scale * 1.1
);
lightGrad.addColorStop(0, 'rgba(255,255,252,0.07)');
lightGrad.addColorStop(0.3, 'rgba(255,255,250,0.04)');
lightGrad.addColorStop(0.7, 'rgba(245,245,240,0.01)');
lightGrad.addColorStop(1, 'rgba(240,240,235,0)');
ctx.fillStyle = lightGrad;
ctx.fillRect(0, 0, W, H);
// ── 额外的顶部线性光照(模拟上方光源照射墨水团) ──
const topLightGrad = ctx.createLinearGradient(0, 0, 0, H * 0.5);
topLightGrad.addColorStop(0, 'rgba(255,255,252,0.05)');
topLightGrad.addColorStop(1, 'rgba(255,255,250,0)');
ctx.fillStyle = topLightGrad;
ctx.fillRect(0, 0, W, H * 0.5);
// ── 微妙的中心光晕(墨水团在光照下的散射光) ──
const centerGlow = ctx.createRadialGradient(
centerX + scale * 0.02, centerY - scale * 0.04, scale * 0.01,
centerX, centerY, scale * 0.35
);
centerGlow.addColorStop(0, 'rgba(180,175,200,0.03)');
centerGlow.addColorStop(0.5, 'rgba(140,135,170,0.015)');
centerGlow.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = centerGlow;
ctx.fillRect(0, 0, W, H);
}
// ─────────────────────────────────────
// 主循环
// ─────────────────────────────────────
let lastTime = 0;
let animStartTime = 0;
let animRunning = false;
let burstDone = false;
function animate(timestamp) {
if (!animRunning) {
animRunning = true;
animStartTime = timestamp;
lastTime = timestamp;
spawnInitialBurst(0);
burstDone = true;
}
let dt = (timestamp - lastTime) / 1000;
if (dt <= 0) dt = 0.016;
if (dt > 0.1) dt = 0.1; // 防止大帧跳跃
lastTime = timestamp;
const elapsed = (timestamp - animStartTime) / 1000;
// 更新涡旋位置
updateVortices(elapsed);
// 持续发射
spawnContinuous(elapsed, elapsed);
// 入水气泡
spawnSplashBubbles(elapsed);
// 物理更新
updateParticles(dt, elapsed, elapsed);
// 渲染
render();
requestAnimationFrame(animate);
}
// ─────────────────────────────────────
// 启动
// ─────────────────────────────────────
// 短暂延迟后启动,确保页面完全加载
setTimeout(() => {
requestAnimationFrame(animate);
}, 100);
// 点击画布可重新触发墨滴入水
canvas.addEventListener('click', () => {
// 清空现有粒子
particles.length = 0;
animStartTime = performance.now();
burstDone = false;
spawnInitialBurst(0);
burstDone = true;
});
// ─────────────────────────────────────
// 触摸支持(移动端)
// ─────────────────────────────────────
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
particles.length = 0;
animStartTime = performance.now();
burstDone = false;
spawnInitialBurst(0);
burstDone = true;
}, { passive: false });
})();
</script>
</body>
</html>
1.20s
TTFT
118
Reasoning TPS
83.9s · ≈9,891 tok
133
Output TPS
32.6s · ≈4,320 tok
14,211
Total Tokens (official)
Input 130
Elapsed 120s · Average 122 tok/s · Peak 450 tok/s · Cost ≈¥0.115
Kimi 月之暗面🥇
kimi-for-coding
We need generate single file HTML, native canvas, simulating black ink drop into water, diffusion, turbulence, fractal tendrils, advection and diffusion, density variation, soft top lighting, 60fps, no external libs. Only output complete code, no explanation. Must ensure code works, maybe using fluid simulation (Navier-Stokes) on GPU via canvas 2D? Could implement grid-based fluid solver in JS. Need render black ink with density field. Need top soft lighting: render ink with shading, maybe gradient light from top. Use WebGL? Canvas 2D can do per-pixel image manipulation but 256x256 grid maybe OK for 60fps. Need fractal-like appearance. Can use fluid simulation with curl noise, adding density and velocity impulse. Use grid resolution e.g. 256x256 or 128x128. Advect velocity and density. Add vorticity confinement to get swirling tendrils. Add dye with high density at center and some noise. Render using pixel manipulation or using Canvas 2D radial gradients per cell? For performance, use ImageData and draw to canvas. Top lighting: compute light gradient overlay or modulate opacity based on y? We can precompute background gradient and composite ink with alpha from density, plus maybe specular lighting top. Could use WebGL for speed but native Canvas 2D is allowed. Use ImageData and direct pixel manipulation. Fluid solver: Use standard approach from Jos Stam or custom. For high-quality tendrils, use a staggered grid, advect, diffuse, project, vorticity confinement. Implement in plain JS, no external. Resolution 256x256 maybe 60fps. Let's design.
Need output a single HTML complete code. We'll include full page style. Use a canvas with CSS full screen. Fluid grid of size maybe 256x256; render scaled. Input: automatically start with drop. Maybe click to drop.
Plan:
- Constants: N = 256, iter = 8 or 16, dt=0.005? For stability. Actually for N=256, dt 0.004 maybe. Use scale=1/N.
- Arrays: u, v (velocity x,y size (N+2)*(N+2)), u0,v0, dens, dens0. Also curl array.
- Functions: add_source, diffuse, advect, project, vorticity confinement. Boundaries: treat as closed or open? For ink in a glass, maybe closed; set velocity zero at boundaries.
- addInk(x,y, amount, radius): add density with radial falloff plus noise. Add radial outward velocity with noise to simulate splash, plus random vorticity.
- Step: add sources (ink), diffuse velocity, project, advect velocity, project, vorticity confinement (add to velocity), advect density, decay.
- Top lighting: density to alpha. Render: background radial gradient light, then for each pixel compute alpha = density*some factor. For black ink, color maybe dark grey/black but with depth. Top lighting could simulate light scattering: alpha varies with vertical gradient and a highlight at top? We can produce visual: black ink alpha = density. Add a soft top light overlay: a vertical gradient from lighter at top to darker bottom, blended with ink using 'source-atop'? To look like illuminated water. Maybe render background as a blueish-white gradient. Then render ink as black with alpha density; apply lighting by making top areas brighter/ink less dense? Since black ink absorbs light, top light would illuminate the upper portion of the liquid, making lower darker. Could use a gradient fill over the ink after drawing with multiply/overlay. Simpler: compute pixel color as lerp(backgroundColorAtY, inkColor, alpha). backgroundColor lighter at top. That gives top soft lighting: the visible water is lighter at top. Add specular highlight at top maybe.
- Density variation: initial density with noise: high core, low tendrils. Use fractal noise? Could use simple pseudo-random radial noise. But the prompt asks for fractal tendrils from fluid, not initial. The solver with vorticity will create fractal-like structures. Initial density can be a blob with rough edges.
- Performance: per frame loops over (N+2)^2 ~ 66k for several passes (diffuse iter 8, advect). Should be okay ~60fps at N=256? Several loops over 66k each pass: maybe 10 passes per step -> 660k operations, JS okay.
- Rendering: loop over interior N*N set ImageData. Use typed arrays.
- Need ensure 60fps: use requestAnimationFrame. Could run multiple simulation steps per frame? With dt small. Since dt=0.005, need ~6 steps per frame for real-time? Actually simulation time not necessarily real. Could set dt = 0.01 and 1 step per frame. Stability CFL condition: max velocity < N*dt. If velocities moderate, fine.
- Vorticity confinement: compute curl = du/dy - dv/dx; find gradient magnitude of |curl|; N vector = grad(|curl|)/|grad|; add force = epsilon * h * (N x curl). epsilon=2. Need handle boundaries.
- Boundaries: For velocity, set continuous (reflect) or no-slip? For ink in glass, walls at boundary no-slip? Use closed: u=0 at walls. For density, Neumann (zero derivative) to keep inside. Use set_bnd with b parameter.
- Ink decay/dissipation: multiply density by 0.997 each frame, but with continuous injection maybe? Let's auto inject periodically or initial big drop. Could on click add ink. Start with a drop at center. Maybe after delay add smaller drops.
- Visual background: a circle representing glass? Could clip to circle. Render square canvas with gradient background; optional draw a circular vignette. Since prompt says "a cup of clear water", maybe draw a circular container. Use radial gradient background.
- Use dithering or noise to avoid banding in gradient.
- Need final answer only code. Ensure code is syntactically correct.
Potential issue: JS fluid solver with project iterations 8 may be slow but okay. Let's write efficient loops using flat index. N=256 => size=(N+2)=258 => arrays length 66564. Loops for i=1..N, j=1..N. Use a function IX(i,j) = i + size*j.
Implement functions:
function add_source(x, dt, s, d) { for i d[i]+=dt*s[i]; }
function set_bnd(b, x) { ... }
function lin_solve(b, x, x0, a, c) { for k=0;k<iter;k++ { for i,j x[IX(i,j)]=(x0[IX(i,j)]+a*(x[IX-1]+x[IX+1]+x[IX-size]+x[IX+size]))/c; set_bnd(b,x); } }
function diffuse(b, x, x0, diff, dt) { var a=dt*diff*N*N; lin_solve(b,x,x0,a,1+4*a); }
function advect(b, d, d0, u, v, dt) { ... backtrace with interpolation. Use dt0=dt*N. }
function project(u,v,p,div) { compute div, set p=0, lin_solve 0, subtract gradient, set bounds. }
function vorticity_confinement(u,v) { compute curl from u,v; compute grad length; add force. }
Ink injection:
function dropInk(cx,cy) {
radius ~ 0.08*N. For each cell within radius, d = distance, falloff = exp(-d*d/(r*r)); add density amount*falloff*(1+noise). Also add outward velocity with noise: angle, plus random swirl. }
We can use seeded noise function for deterministic or Math.random. To get fractal tendrils, maybe add high-frequency noise to velocity. Use a simple pseudo-noise function based on sin.
Rendering:
- Create offscreen ImageData N x N.
- background colors: precompute bg pixels for a soft top light. Use a circular water region. For each cell, if outside circle maybe transparent? But canvas full screen, could show outside. Let's compute a base color in a separate Uint8ClampedArray bg or compute on the fly. We can render background via CSS gradient and draw ink over using alpha. But prompt asks top soft lighting. Simpler: draw background gradient on canvas each frame with canvas 2D fillRect; then draw ink via ImageData with alpha? Can't directly composite ImageData alpha over existing gradient using putImageData (it replaces). Instead render ink to a temporary canvas (or offscreen) and draw it over background with drawImage. That allows compositing. We can create a small offscreen canvas of size N x N, put ImageData, then draw it scaled to main canvas over background. Use context.globalCompositeOperation = 'source-over' (default). For top light, we can also draw a radial/linear gradient overlay after ink with low opacity.
- To render ink with density variation and soft edges, we can create ImageData for ink: RGBA = (0,0,0, alpha). Alpha = min(255, density*255*3). But density may exceed 1; we scale. Use density smooth: alpha = 255*(1-exp(-density*3)) maybe. That gives deep core. But top light? If we draw black over light background, it simulates silhouette; top light could be an overlay gradient after ink. But if background is gradient light from top, top parts remain light, bottom dark. Also add soft specular highlight at top: a white gradient overlay with low opacity and blend 'overlay' maybe.
- Maybe use actual 3D-ish shading: For each pixel, compute lighting based on density gradient? Too expensive. We'll do overlay.
Implementation details:
- Main canvas CSS full viewport. Offscreen canvas for simulation at N x N. Main ctx draws background (a radial gradient from top), then draws sim canvas scaled. Then draws top-light overlay (linear gradient white-transparent) with low opacity. Maybe also a vignette circle.
- Start animation after load. Add initial drop and maybe subsequent droplets.
Potential issue: sim canvas scaling with drawImage uses nearest neighbor? canvas drawImage default uses bilinear filtering, smooths ink. That's good.
- Use canvas.style.image-rendering? We want smooth. Default auto.
- Need ensure no text/explanations in final answer. Just code block.
Let's craft the code carefully.
Fluid solver arrays initialization:
const size = N + 2;
const total = size*size;
const u = new Float32Array(total), v=..., u0=..., v0=..., dens=..., dens0=..., curl=..., curl_abs=... maybe use temp arrays for vorticity.
Project:
function project(u, v, p, div) {
for i,j: div[IX(i,j)] = -0.5*(u[IX(i+1,j)] - u[IX(i-1,j)] + v[IX(i,j+1)] - v[IX(i,j-1)])/N;
p[IX(i,j)] = 0;
set_bnd(0,div); set_bnd(0,p);
lin_solve(0,p,div,1,4);
for i,j: u[IX(i,j)] -= 0.5*N*(p[IX(i+1,j)] - p[IX(i-1,j)]);
v[IX(i,j)] -= 0.5*N*(p[IX(i,j+1)] - p[IX(i,j-1)]);
set_bnd(1,u); set_bnd(2,v);
}
set_bnd: if b==1, x[0,j]=-x[1,j], x[N+1,j]=-x[N,j]; if b==2 y? Actually for velocity components, reflect sign normal. For density b==0, copy. But for closed container, maybe no-slip: u,v zero at boundaries. For density, zero outside. To keep ink in glass, use Neumann for density (b=0 copy) and no-slip for velocity (set zero). Simpler: set_bnd with b parameter as in Stam. For density, b=0 => copy. For u (x-velocity), b=1 => negate at x-walls, copy at y-walls. For v (y-velocity), b=2 => negate at y-walls, copy at x-walls. This approximates walls. Fine.
But for velocity, no-slip at boundaries set to 0. We can just set all boundaries to 0 for u,v. Use set_bnd with negate gives zero at walls if interior is zero but if fluid hits wall, reflection. Let's use standard.
Advect with interpolation:
var dt0 = dt*N;
for i=1..N for j=1..N:
x = i - dt0*u[IX(i,j)]; y = j - dt0*v[IX(i,j)];
if(x<0.5) x=0.5; if(x>N+0.5) x=N+0.5; i0=floor(x); i1=i0+1;
if(y<0.5) y=0.5; if(y>N+0.5) y=N+0.5; j0=floor(y); j1=j0+1;
s1=x-i0; s0=1-s1; t1=y-j0; t0=1-t1;
d[IX(i,j)] = s0*(t0*d0[IX(i0,j0)] + t1*d0[IX(i0,j1)]) + s1*(t0*d0[IX(i1,j0)] + t1*d0[IX(i1,j1)]);
Curl confinement:
Compute curl = (u[IX(i,j+1)] - u[IX(i,j-1)])/(2h) - (v[IX(i+1,j)] - v[IX(i-1,j)])/(2h). But with N grid, h=1/N. We'll compute simpler using differences not divided: cw = u[IX(i,j+1)] - u[IX(i,j-1)] - v[IX(i+1,j)] + v[IX(i-1,j)]. Then abs. Then gradient of abs:
dx = (abs[i+1]-abs[i-1])/2; dy = (abs[i,j+1]-abs[i,j-1])/2; length = sqrt(dx*dx+dy*dy)+1e-5;
nx=dx/len; ny=dy/len;
force = eps * cw * (ny*? cross product). In 2D, force vector = epsilon * h * (N x curl_z) = epsilon * h * (ny*cw, -nx*cw)? Actually N = (nx,ny), omega = (0,0,cw). N x omega = (ny*cw, -nx*cw, 0). So add to u: + force * ny*cw; to v: - force * nx*cw. Where force = eps / h? There are formulas. We'll use small constant.
Implement vorticityConfinement(u,v):
for interior compute curl_abs array.
for interior compute grad, length; fx=eps*(gradY/len)*cw; fy=-eps*(gradX/len)*cw; u[IX]=dt*fx; v[IX]=dt*fy? Or add to velocity arrays directly. We'll add to u0/v0 as source before project.
We can incorporate vorticity confinement as a force added before projection.
Step sequence:
1. add sources to dens: e.g. if drop scheduled, add density and velocity to u0/v0 arrays.
2. add u0 to u, v0 to v (add_source).
3. vorticity confinement: compute and add to u/v maybe via u0/v0.
4. diffuse u into u0, v into v0 (swap), project.
5. advect u0 back to u, v0 back to v, project.
6. add dens source (dens0) to dens, diffuse dens into dens0, advect dens0 back to dens, decay.
Actually standard Stam:
- velocity: u += u0*dt; v += v0*dt; diffuse(u,u0,visc,dt) => u0; project(u0,v0,u,v); advect(u,u0,u0,v0,dt) => u; advect(v,v0,u0,v0,dt)=>v; project(u,v,u0,v0).
For vorticity, add confinement to velocity before or after advect.
Density:
- dens += dens0*dt; diffuse(dens,dens0,diff,dt) => dens0; advect(dens0,dens,u,v,dt)=>dens.
Maybe simpler to avoid confusion.
Parameter values:
N=256, dt=0.005 maybe, visc=0.000001, diff=0.00001, iter=8. Decay 0.998. vorticity epsilon ~ 2.0? Since dt small, maybe 5.0.
Ink injection:
function addDrop(cx,cy, strength) {
const r = 0.05*N; // 12 cells
for j=1..N for i=1..N: dx=i-cx, dy=j-cy, d2=dx*dx+dy*dy; if(d2<r*r*4) { falloff=Math.exp(-d2/(r*r)); dens0[IX]+=strength*falloff*(1+0.4*(Math.random()-0.5)); // add noise
angle=Math.atan2(dy,dx); amp=strength*falloff*0.5;
u0[IX]+=amp*(Math.cos(angle)+0.5*(Math.random()-0.5));
v0[IX]+=amp*(Math.sin(angle)+0.5*(Math.random()-0.5));
}
}
Then set boundaries for source arrays maybe not needed, they get added and then cleared.
Auto drop at start and every few seconds.
Rendering details:
- Main canvas resolution maybe match display size for crisp. We'll set canvas width=window.innerWidth, height=window.innerHeight. On resize adjust.
- Offscreen simCanvas size N x N.
- Create simCtx = simCanvas.getContext('2d'); simImageData = simCtx.createImageData(N,N). Fill alpha per density.
- Draw background: mainCtx fillRect with radial gradient from top center. Use mainCtx.fillStyle = radial gradient colors: '#e8f4ff' center top to '#b0c4de' or '#8099aa' edges. Actually water in glass.
- Draw simCanvas scaled to fit inside a circle maybe. We can clip to circle: mainCtx.beginPath(); arc(cx,cy,r,0,2PI); clip(); drawImage. Then restore. Add glass rim maybe.
- Top soft lighting overlay after ink: create linear gradient top to bottom: rgba(255,255,255,0.35) to rgba(0,0,0,0.2). fill over circle with globalCompositeOperation maybe 'overlay' or 'source-atop'. Use 'source-atop' to only affect ink/water region inside clip. Since after clip restore, we can clip again.
- Add specular highlight: radial gradient white transparent at top, low opacity.
Potential problem: using clip twice. We'll structure draw() function.
Let's write code.
Need ensure arrays are Float32Array or regular arrays. Use Float32Array for speed. For add_source simple loop.
Edge cases: Math.exp heavy for each cell each drop. Drops only occasionally. Fine.
Vorticity confinement may produce instability; keep epsilon small and dt small. Use dt=0.004, 2 steps per frame? Or one step. Let's use dt=0.008 and perhaps 2 substeps? Stability: advect CFL: max velocity ~ maybe 5; dt0=dt*N*vel = 0.008*256*5=10.24 > 1 cell per step causing maybe instability. Better dt=0.002, run 3 steps per frame (dt_total=0.006 per frame), slower real-time. Simulation doesn't need match real time. Use dt=0.005 per step and run 1 step per frame. Control velocities to be small. Strength = 8 maybe; r=15; amp = strength*falloff*0.3; max u ~2.4; dt0=0.005*256*2.4=3 >1. Too high. Maybe N=128 instead for larger cells? With N=128, dt0 with vel 2.4 =1.5. Hmm. Use dt smaller 0.002 and maybe run 2 steps per frame. Or use N=200. Let's choose N=180? Not nice. Let's set N=200, dt=0.003, run 2 steps per frame -> 0.006. velocities ~ maybe 2; dt0=0.003*200*2=1.2. Acceptable. Or N=160, dt=0.004. Let's use N=256 but dt=0.002 and 2 steps per frame. dt0=0.002*256*2=1.0. Good. iter=8. Performance with 2 steps per frame: per step loops ~ many, 60fps maybe okay. We can test mentally. 256*256=65k, per step: add source, diffuse vel (lin_solve iter8 over 65k*8*2? Actually diffuse for u and v each 8 iterations -> 1M ops each), project (div/p loops + lin_solve 8 -> ~1M), advect (65k), density diffuse+advect (~70k*8 + 65k). Total per step ~3M ops, times 2 =6M per frame. JS can do ~100M ops/s maybe 60fps uses 360M/s. Could be heavy. Use N=192 or 160. Let's pick N=192 (size 194, area 37636). Per step ~1.8M, *2=3.6M. Good. ImageData 192x192 scaled to full screen; bilinear smoothing acceptable, will look soft. But prompt wants high detail; maybe 256 but run 1 step per frame with dt=0.003 and limit velocities. Let's choose N=256, dt=0.003, 1 step per frame. Need keep max velocity < 1/(dt*N)=1.56 cell per step. Ink velocities maybe ~1. We can adjust injection amplitude. Use dt=0.003, iter=12 for projection. Total ops per step ~4M, 60fps 240M/s, maybe okay on modern but not guaranteed. Use N=224 as compromise. But code can be tuned. Let's choose N=256 and use dt=0.005 but clamp velocities to 1.5? Could clamp after injection and advect. Simpler: N=256, dt=0.004, 1 step per frame, injection amplitude moderate. Run performance okay? I think 60fps might be okay. Let's optimize loops using local vars and no function calls inside hot loops. But for readability maybe okay.
We could also implement advection using a semi-Lagrangian stable. We can clamp velocities by limiting dt. Use dt=0.002 and run 2 substeps per frame but only update render every frame. Hmm.
Let's decide N=256, dt=0.004, steps=1. iter=8. Injection strength=5, r=12, velocity amp= strength*falloff*0.2 => max 1. dt0=1*256*0.004=1.024. Acceptable maybe borderline. Use dt=0.0035 => 0.9. Good.
Or use a non-grid particle approach? The prompt explicitly says real fluid motion (advection and diffusion). Grid is fine.
Let's write a robust solver. Use N=256, dt=0.004, iter=12. We'll add a velocity cap in step: for each cell if |u|>maxVel set to sign*maxVel. maxVel = 0.9/(N*dt) ~ 0.88. But after projection velocities may grow? Usually stay bounded. We'll cap after advect and after confinement.
Actually for semi-Lagrangian stability, CFL condition: dt0*maxVel <=1. dt0=N*dt. So maxVel <= 1/(N*dt). For N=256, dt=0.004 => 0.976. We'll cap u,v to 0.95. Injection velocity amplitude ~0.5. Good.
Set diff = 0.0001, visc=0.00005. vorticity epsilon=2.0. But epsilon adds force; dt*epsilon/h... With h=1/N, formulas use epsilon large maybe. We can use confinementStrength=0.00005? Let's derive. Standard vorticity confinement force: f = ε h (N × ω). In code, f = ε * (N × curl). Here h = 1/N. If we compute curl as difference not divided by 2h, then curl ~ O(h*|v|). But N dimensionless. Use small epsilon ~0.2 to 2. Let's use 1.5 and multiply by dt. We'll adjust.
Potential issue: add_source function uses dt (the simulation timestep). We add density directly with falloff, not multiplied by dt; but standard adds s*dt. We can treat source as total added per drop over a few frames. We'll add density to dens0 (source) and then dens += dens0*dt. So define source arrays as amount per second; addDrop sets high values. Use dens0[IX] += strength*falloff. Then step dens += dens0*dt, then decay. Since drop is one frame event, with dt=0.004, density added = strength*dt*falloff. If strength=1000, add ~4 per cell. Hmm. Better add directly to dens for immediate impact and then decay; skip source*dt. We'll add density directly to dens (not dens0) on drop, and velocity directly to u/v (or u0/v0 with dt). Simpler: addDrop modifies dens array directly and u0/v0 source arrays (which will be cleared each step after adding). For velocity, add to u0 with large value, then u += u0*dt. So injection velocity = source*dt. If source=200, dt=0.004 gives u=0.8. Good. For density, add directly maybe amount=2.0*falloff. Then after advect/diffuse, core density high ~2-5. Alpha map uses density.
Let's write code from scratch in final answer. We need not include explanations, but code comments allowed? The instruction says single file HTML, only output complete code, no explanation. Comments within code are okay but not explanation. Keep minimal comments.
Let's construct final HTML:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ink Drop</title>
<style>
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#0b0f14;}
canvas{display:block;}
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
... code ...
</script>
</body>
</html>
Inside script:
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let W, H, CX, CY, R;
function resize(){ W=canvas.width=innerWidth; H=canvas.height=innerHeight; CX=W/2; CY=H/2; R=Math.min(W,H)*0.45; }
window.addEventListener('resize', resize); resize();
Fluid constants:
const N = 256;
const SIZE = N+2;
const TOTAL = SIZE*SIZE;
const dt = 0.004;
const visc = 0.00005;
const diff = 0.00008;
const iter = 12;
const decay = 0.9985;
const vorticity = 1.2;
Arrays:
const u = new Float32Array(TOTAL);
const v = new Float32Array(TOTAL);
const u0 = new Float32Array(TOTAL);
const v0 = new Float32Array(TOTAL);
const dens = new Float32Array(TOTAL);
const dens0 = new Float32Array(TOTAL);
const curl = new Float32Array(TOTAL);
const curlAbs = new Float32Array(TOTAL);
function IX(i,j){ return i + SIZE*j; }
function set_bnd(b, x){
for(let i=1;i<=N;i++){
x[IX(0,i)] = b===1 ? -x[IX(1,i)] : x[IX(1,i)];
x[IX(N+1,i)] = b===1 ? -x[IX(N,i)] : x[IX(N,i)];
x[IX(i,0)] = b===2 ? -x[IX(i,1)] : x[IX(i,1)];
x[IX(i,N+1)] = b===2 ? -x[IX(i,N)] : x[IX(i,N)];
}
x[IX(0,0)] = 0.5*(x[IX(1,0)]+x[IX(0,1)]);
x[IX(0,N+1)] = 0.5*(x[IX(1,N+1)]+x[IX(0,N)]);
x[IX(N+1,0)] = 0.5*(x[IX(N,0)]+x[IX(N+1,1)]);
x[IX(N+1,N+1)] = 0.5*(x[IX(N,N+1)]+x[IX(N+1,N)]);
}
function lin_solve(b, x, x0, a, c){
const invC = 1/c;
for(let k=0;k<iter;k++){
for(let j=1;j<=N;j++){
const row = j*SIZE;
const rowUp = row - SIZE;
const rowDown = row + SIZE;
for(let i=1;i<=N;i++){
const id = row + i;
x[id] = (x0[id] + a*(x[id-1] + x[id+1] + x[rowUp+i] + x[rowDown+i])) * invC;
}
}
set_bnd(b,x);
}
}
function diffuse(b, x, x0, diff, dt){
const a = dt*diff*N*N;
lin_solve(b,x,x0,a,1+4*a);
}
function advect(b, d, d0, uu, vv, dt){
const dt0 = dt*N;
for(let j=1;j<=N;j++){
for(let i=1;i<=N;i++){
let x = i - dt0*uu[IX(i,j)];
let y = j - dt0*vv[IX(i,j)];
if(x<0.5) x=0.5; if(x>N+0.5) x=N+0.5;
if(y<0.5) y=0.5; if(y>N+0.5) y=N+0.5;
const i0 = x|0; const i1 = i0+1;
const j0 = y|0; const j1 = j0+1;
const s1 = x-i0; const s0 = 1-s1;
const t1 = y-j0; const t0 = 1-t1;
const i0j0 = IX(i0,j0), i1j0 = IX(i1,j0), i0j1 = IX(i0,j1), i1j1 = IX(i1,j1);
d[IX(i,j)] = s0*(t0*d0[i0j0] + t1*d0[i0j1]) + s1*(t0*d0[i1j0] + t1*d0[i1j1]);
}
}
set_bnd(b,d);
}
function project(u, v, p, div){
const h = 1/N;
for(let j=1;j<=N;j++){
for(let i=1;i<=N;i++){
div[IX(i,j)] = -0.5*h*(u[IX(i+1,j)] - u[IX(i-1,j)] + v[IX(i,j+1)] - v[IX(i,j-1)]);
p[IX(i,j)] = 0;
}
}
set_bnd(0,div); set_bnd(0,p);
lin_solve(0,p,div,1,4);
for(let j=1;j<=N;j++){
for(let i=1;i<=N;i++){
const id = IX(i,j);
u[id] -= 0.5*(p[IX(i+1,j)] - p[IX(i-1,j)])/h;
v[id] -= 0.5*(p[IX(i,j+1)] - p[IX(i,j-1)])/h;
}
}
set_bnd(1,u); set_bnd(2,v);
}
Vorticity confinement: We'll compute curl and add to u0/v0 as source.
function addVorticity(u,v,u0,v0){
// compute curl = du/dy - dv/dx using central diff (scale doesn't matter for force direction)
for(let j=1;j<=N;j++){
for(let i=1;i<=N;i++){
curl[IX(i,j)] = (u[IX(i,j+1)] - u[IX(i,j-1)]) - (v[IX(i+1,j)] - v[IX(i-1,j)]);
curlAbs[IX(i,j)] = Math.abs(curl[IX(i,j)]);
}
}
const eps = vorticity;
for(let j=1;j<=N;j++){
for(let i=1;i<=N;i++){
const id = IX(i,j);
const dx = curlAbs[IX(i+1,j)] - curlAbs[IX(i-1,j)];
const dy = curlAbs[IX(i,j+1)] - curlAbs[IX(i,j-1)];
const len = Math.sqrt(dx*dx+dy*dy) + 1e-8;
const nx = dx/len;
const ny = dy/len;
const c = curl[id];
u0[id] += eps * dt * (ny * c);
v0[id] += eps * dt * (-nx * c);
}
}
}
Need ensure u0/v0 are reset each frame before adding drops? We'll zero them at start of step. Then addDrop writes to u0/v0 and dens directly. Then addVorticity adds to u0/v0. Then add_source u += u0*dt etc. Wait addVorticity uses dt to scale, good. After adding to u0, we will add_source u += u0*dt, which adds vorticity force scaled by dt again? That would be dt^2. Better addVorticity directly modifies u/v (adds force*dt), and then u0/v0 are separate sources. Let's design step:
- Zero u0/v0/dens0 each step (or just use u0/v0 for drop).
- addDrop writes velocity impulse into u0/v0 (as source strengths). It can also write density into dens0 source or dens directly.
- add_source(u, u0, dt), add_source(v, v0, dt).
- Then addVorticity modifies u/v directly (or writes into u0 then add_source). If we write directly to u/v as acceleration*dt, no extra dt. Let's implement addVorticity to add to u,v directly with scale eps * dt (force times dt). Since we haven't diffused yet, it's okay.
- Then diffuse/project/advect/project.
So step:
function step(){
// sources
add_source(u,u0,dt);
add_source(v,v0,dt);
zero(u0); zero(v0);
addVorticity(u,v); // modifies u,v
// velocity step
diffuse(1,u0,u,visc,dt);
diffuse(2,v0,v,visc,dt);
project(u0,v0,u,v);
advect(1,u,u0,u0,v0,dt);
advect(2,v,v0,u0,v0,dt);
project(u,v,u0,v0);
// density step
// dens source from drop maybe dens0; add it
add_source(dens,dens0,dt);
zero(dens0);
diffuse(0,dens0,dens,diff,dt);
advect(0,dens,dens0,u,v,dt);
// decay & cap
for(let i=0;i<TOTAL;i++){
dens[i] *= decay;
if(dens[i]<0.0005) dens[i]=0;
}
// cap velocity
const maxV = 0.95/(N*dt);
for(let i=0;i<TOTAL;i++){
if(u[i]>maxV) u[i]=maxV; else if(u[i]<-maxV) u[i]=-maxV;
if(v[i]>maxV) v[i]=maxV; else if(v[i]<-maxV) v[i]=-maxV;
}
}
Wait addVorticity modifies u/v before diffuse; but diffuse uses u as x0 to produce u0. Good. However vorticity confinement should be applied to post-advect velocity? Applying before diffuse still injects swirl. Fine.
add_source: function add_source(x,s,dt){ for i x[i]+=dt*s[i]; }
addDrop: writes to u0/v0 source and dens0? Since step does dens += dens0*dt, if we add huge source only for one frame, density
This run finished without a final content stream. The provider only returned reasoning/intermediate tokens, so the reasoning panel above is expanded instead of showing a blank result.
1.41s
TTFT
202
Reasoning TPS
40.5s · ≈8,192 tok
—
Output TPS
8,192
Total Tokens (official)
Input 120
Elapsed 42.7s · Average 202 tok/s · Peak 579 tok/s · Cost ≈$0.033
Kimi 月之暗面-high-think-budget🥈
kimi-for-coding
We need write single-file HTML using native canvas implementing black ink drop into clear water, diffusion animation. Requirements: no explanation, only complete code. Need realistic fluid motion advection and diffusion, fractal tendrils, density variation, deep core, wispy edges, soft top lighting, 60fps, no external libs.
We need craft code. This is a coding task. Need produce HTML with canvas and JS. Since cannot use WebGL? Native Canvas 2D. Implement grid-based fluid simulation (Navier-Stokes-like) on CPU, then render density with lighting and opacity. Single file. 60fps maybe possible at modest resolution (e.g., 128x128 grid, scaled via CSS). Use semi-Lagrangian advection, diffusion, projection. Add ink source impulse. Render with radial gradients/shadows per cell? For wispy fractal, we can also add curl noise in addition to density? We can add dye with high detail from velocity textures. Need careful performance. We can use small grid, e.g., N=150 or 200, with iterations maybe 20 for projection. 60fps target. We'll optimize by using Float32Array and inline loops. HTML full. Also top lighting: Render lighter at top? Maybe composite density and use gradient overlay. Use shadows and highlights.
Need to ensure code is self-contained. Let's design.
Concept: Create a 2D grid of size width=200 height maybe 160 (or based on canvas size but pixel simulation lower res). We'll have arrays: u,v (velocity), u_prev, v_prev, density, density_prev. Fields sizes (w+2)*(h+2) for boundary. Algorithms from Stam 2003 stable fluids. Add forces: at center drop, add high upward/outward impulse plus random swirling noise to create fractal tendrils. Then each frame step fluid.
To enhance fractal detail, we can inject high-frequency curl noise into velocity field (procedural vector field) periodically or in force step: vorticity confinement? Could add noise based on curl. Simpler: add force from a procedural noise function with multiple octaves to velocity at drop region. With advection, this creates fractal-like tendrils. Also use high density gradient and soft rendering.
Rendering: For each grid cell, draw a semi-transparent circle/ellipse with radius proportional to density and gradient. But for 200x160 grid = 32k cells, drawing per cell each frame might be heavy but okay if simple rects? To achieve feathered edges and density variation, use one canvas at sim res, draw each cell as a small filled rect with alpha = density*scale. But edges would be pixelated. We can upscale canvas with CSS smoothing. That yields soft but blocky. Could render using a radial gradient per cell? Heavy. Alternative: draw density field onto low-res canvas using putImageData with alpha, then apply blur? Blur filter on canvas is slow. Could use shadowBlur? No.
Better: Use `getImageData` and set pixels alpha based on density, with smoothing via bilinear? But pixelated edges. However because of diffusion and low res, upscaled might look like smooth smoke. Actually if we render to sim canvas and CSS `image-rendering: auto` with smoothing, it will blur into a soft image; tends to look like smoke. Top lighting can be done by overlaying a vertical gradient after drawing.
But to get density variation (deep core, wispy edge) we can render the density as alpha; deep core high alpha; edges low alpha. Because alpha max maybe 0.05 per cell, accumulation creates core.
Implementation plan:
- Canvas element full window or fixed centered. We'll make canvas size maybe 512x512 or 1024? We'll render at simulation resolution (N=160, M=120) and draw scaled to canvas width/height via drawImage smoothing. This gives high FPS.
- Fluid arrays: size = (w+2)*(h+2). Use Float32Array.
- Functions:
- add_source(x, s, dt)
- set_bnd(b, x): handle boundaries. For density, value 0 at walls (continuity). For velocity, no-slip.
- lin_solve(b, x, x0, a, c, iterations): Gauss-Seidel. Use red-black? Keep simple with maybe 10-20 iterations.
- diffuse(b, x, x0, diff, dt): a = dt*diff*w*h, lin_solve.
- advect(b, d, d0, u, v, dt): semi-Lagrangian backtrace with interpolation.
- project(u, v, p, div): compute divergence, solve pressure Poisson, subtract gradient.
- step(): add sources/forces, diffuse velocities, project, advect velocities, project, diffuse density, advect density.
- Force/ink: At t=0 or triggered repeatedly? User wants animation of drop. We can initialize with drop and then let diffuse. But should start with drop hitting water. We'll trigger at frame 0 and maybe a second pulse. At each step before solving, add density and velocity based on a Gaussian blob at center. Add a strong downward? Actually ink drop enters from top and sinks/diffuses. We can create drop at center top and add downward velocity and outward radial/tangential force. But prompt says black ink drop into a cup of clear water. Top lighting. We can simulate whole cup; drop at top center. But to fit "ink drop into a cup of water" maybe central drop. Let's do central with initial radial burst. Add velocities: outward plus swirling. Use a noise vector field to create fractal tendrils. Add density Gaussian.
- To generate fractal tendrils, we can add a divergence-free noise velocity field. We can compute from potential with sine/cosine octaves: psi(x,y) = sum sin(freq*x + phase) * cos(freq*y). Then velocity = (d psi/dy, -d psi/dx). Add scaled by radial mask around center. This yields turbulent fractal structure.
- Add vorticity confinement maybe, but procedural noise should suffice. Maybe also add random velocity seed each frame? But stable fluids advect will preserve. We'll add initial noise and also small continuous noise to maintain detail as it dissipates.
- Dye injection: Add density at center with Gaussian. Stop adding after few frames (e.g., 60 frames) to simulate drop. Then let diffuse/dissipate. Also add velocity impulse each frame for short time.
- Rendering: Use sim canvas element sized to grid; but hidden? Could have visible canvas with CSS scaling; we draw density to it. Use `imageSmoothingEnabled = true`.
- To render top lighting: after drawing density to canvas, use an overlay gradient. For soft top lighting: fill canvas with linear gradient from rgba(255,255,255,0.4) at top to transparent at bottom using `globalCompositeOperation = 'screen'` or `source-atop`? Actually we can composite in render function: after drawing smoke with black alpha, draw vertical gradient from top white (alpha ~0.3) over the whole canvas with `globalCompositeOperation = 'screen'` or normal multiply? But we want light hitting top of ink, making top surfaces lighter. A simple overlay from top to bottom simulates ambient light from top. Use `globalCompositeOperation='overlay'` with white gradient. But drawing gradient over entire canvas may affect background. Better: Use a background clear color light blue/white (cup of water). Ink black. Top lighting: in rendering density field, compute alpha based on density and also modulate by a vertical light gradient (brighter at top). When drawing cell rects with fillStyle = `rgba(0,0,0, alpha * lightFactor)`. This gives top-lit appearance. Then maybe add specular highlight by additional radial gradient at top? Not necessary.
- Performance: Drawing 160x120 rects every frame could be okay but could use putImageData faster. With putImageData, we can set pixel alpha directly with light gradient. But alpha accumulation per pixel. Let's use putImageData for speed and then smoothing on display.
- To simulate density variation deep core, we can map density to alpha with non-linear: alpha = 1 - exp(-density * exposure). This naturally saturates core and translucent edges. We'll set exposure ~30. For each pixel compute `a = 1 - Math.exp(-d * exposure)`. Then multiply by light factor from top. Also we can darken core by using black color. With alpha from density, background visible through edges.
- Top light: For each pixel, compute `light = 1 - (y/h)*0.7` (brighter at top). Or `light = 0.6 + 0.4*(1 - y/h)`. Then final alpha = a * light? Wait if alpha determines ink opacity, top light means ink at top appears lighter (less dense/dark). Actually top lighting on dark ink: lit areas are lighter (gray) not transparent. If we only reduce alpha at top, it becomes transparent not lit. To simulate, we could fill top with white overlay after drawing. But if we multiply alpha by light, top becomes more opaque? Hmm.
Let's think: We render ink as black alpha. Top soft light = add a white overlay gradient from top down. This makes entire top portion of image lighter, including ink (semi-transparent black appears gray when over white) and background. That gives light through water. But prompt: "Top soft lighting" likely highlight from top. We'll draw background first as a soft white-blue. Then draw ink with density alpha. Then draw a vertical white gradient from top with `globalCompositeOperation = 'overlay'` maybe. But `overlay` on canvas with black: black stays black? Overlay: if backdrop <0.5 darkens, >0.5 lightens. Black remains black. Hmm. Maybe use `soft-light` composite to lighten ink from top. Simpler: after drawing ink, set composite `source-atop` and fill gradient: but source-atop only draws on existing ink, lightening top of ink. That would create top-lit ink without affecting background. Good. Use gradient from white transparent to transparent.
However with putImageData on sim canvas, compositing not easy. Instead render directly to display canvas using per-cell rects and composite.
Let's choose rendering via `fillRect` on a display canvas at simulation resolution, then scale? But we can also draw at display resolution if grid matches sim. Simpler: hidden sim canvas for the math and output? Actually we can render to display canvas at sim res (CSS scaling). Use `ctx.fillStyle = rgba(0,0,0, alpha)`, fillRect(cell). Then overlay gradient with composite source-atop. This will look good.
Rect drawing 160x120 = 19,200 per frame, small rects of 4x4? Canvas maybe 640x480 with sim 160x120 => cell 4x4. Fine. We can also skip low-density cells.
For performance, we can loop and compute style maybe costly. Better use `putImageData` and then apply lighting as separate full-canvas gradient overlay. But per-pixel alpha from exposure. Then draw sim canvas to display canvas scaled. Add top gradient overlay on display canvas using source-atop. That seems efficient and quality high.
Let's do:
- sim canvas: width W, height H (grid dimensions) in memory, not appended (or offscreen if supported, but createElement canvas). Use putImageData each frame.
- display canvas: full window, CSS.
- Each frame after simulation: drawImage sim canvas to display canvas scaled.
- Then overlay top light: create gradient displayCtx, composite source-atop, fillRect. But source-atop uses existing alpha (ink). However after drawImage, ink alpha is in display canvas. Good. Then gradient white from top to transparent will lighten top of ink.
But we also want background water color. We can fill display canvas with a soft radial/circular gradient (cup) before drawImage. Use background fill.
For sim canvas putImageData, we set background? putImageData replaces entire pixels; include background? We'll fill display background separately, then draw ink image with alpha. Sim canvas transparent? putImageData can set alpha values; RGB black. It will be transparent where no ink. Good. Need ImageData of W*H.
- Fluid parameters:
- W=180, H=140 maybe. Array length ~ (182)*(142)=25844. Fine.
- dt=0.1 or 0.05. For stability. viscosity=0.0000001, diff=0.00001? Or use high diffusion for smoke. From Stam: diff ~0.001. Need scale with grid. Use diff=0.0005, visc=0.0005. dt maybe 0.2. It works with stable fluids.
- Iterations for projection maybe 10-20. To get 60fps, keep 10. Maybe use N? We'll test conceptually. 180x140 grid, arrays ~25k; loops 25k * operations. With projection 10 iterations each step, plus advect/diffuse etc. Could be ~1-2ms? In JS maybe 5-10ms. 60fps feasible. Let's keep iterations=8.
- Curl noise / fractal force: We can compute an array `noiseU, noiseV` at init. At injection, add to velocity field near center with a radial falloff. The noise should be divergence-free. Formula:
```
let u = 0, v = 0;
for octaves...
u += amp * Math.cos(fx*x + px) * Math.sin(fy*y + py);
v += -amp * Math.sin(fx*x + px) * Math.cos(fy*y + py);
```
This is divergence-free because u=∂ψ/∂y, v=-∂ψ/∂x with ψ= -∑ (amp/fy? ) Actually derivative: u = cos(fx x)*sin(fy y), v = -sin(fx x)*cos(fy y); ∂u/∂x + ∂v/∂y = -fx sin*sin + fy sin*sin? Not zero unless fx=fy. Use same frequency. For divergence-free approximate, use noise based on streamfunction: ψ(x,y)=sum (1/f) sin(f*x) sin(f*y) maybe u=∂ψ/∂y = sin(fx x)*cos(fy y), v=-∂ψ/∂x = -cos(fx x)*sin(fy y). If fx=fy=f, divergence = f sin(fx x)cos(fy y) - f sin? Let's compute: u=sin(fx x)cos(fy y), du/dx=fx cos(fx x)cos(fy y); v=-cos(fx x)sin(fy y), dv/dy=-fy cos(fx x)cos(fy y). If fx=fy, divergence=0. Good. So use same f. Use multiple frequencies.
We'll use frequencies: 1,2,4,8,16 over domain normalized. Amplitudes maybe 0.5,0.25,0.125,0.06,0.03. This yields fractal.
- Initial impulse: At center (cx=W/2, cy=H/2). At frame 0 add large density and outward radial velocity plus turbulent noise. Over next ~30 frames add a little more density? For ink drop, injection should be brief. We'll inject for 5-10 frames then stop. Also add slight continuous low-density source maybe not.
- Boundary conditions: For density, set zero at all walls. For velocity, no-slip (reverse component at walls). This will cause ink to bounce/dissipate at cup boundaries.
- Cup shape: We can render a circular/cylindrical background, maybe with elliptical gradient to simulate cup. But grid is rectangular. We can add boundary radius condition: set density/velocity to zero outside a circle (cup). Or keep rectangular. For visual, draw a cup-shaped radial gradient background. The prompt says "一杯清水" a cup of clear water. We can draw a circular vessel with subtle shading. Use display canvas background: fill radial gradient from center lighter to edge darker. But top lighting might conflict. Maybe a vertical cup background. Could make background radial gradient: center white-blue, edges dark. Ink within.
- Density dissipation: Add a small fade each frame (density *= 0.995). This simulates dispersal and helps performance.
- Use `requestAnimationFrame` with time delta clamped.
- Code organization: inline script. Full HTML. No external libraries. CSS minimal full screen. Maybe use `body { margin:0; overflow:hidden; background:#0b0d10 }` and canvas centered. We'll write code in a self-contained manner.
Potential issues:
- Performance of stable fluids at 60fps. Need maybe optimize by reusing functions with typed arrays and closures. Use ITER=10? Let's choose grid 160x120, ITER=12. dt=0.15.
- We can skip density diffusion and rely on advection and semi-Lagrangian interpolation plus display smoothing. But diffusion gives smoke. Keep density diffuse with few iterations.
- For velocities, diffuse with ITER iterations.
- Projection with ITER iterations.
Let's estimate operations per frame for 160x120=19200 cells:
- add sources: 2 arrays
- diffuse u,v: each lin_solve ITER iterations => 2*ITER loops => 24 loops * 19k = 456k
- project: 3 passes? compute div + solve p ITER + gradient => ~14 loops => 268k
- advect u,v => 2 loops 38k
- project after advect => another 268k
- density diffuse => 12 loops 230k
- density advect => 19k
- render => 19k
Total maybe ~1.2 million operations. In JS at 60fps ~72M ops/sec, likely okay. Could be 10-15ms. Might be okay. We can reduce ITER=8 for velocity projection maybe 8. For density diffusion maybe 6. But to get smooth incompressibility, projection needs enough iterations; with grid moderate, 8 may produce artifacts. Could use smaller grid 128x96. Let's choose W=160,H=120, ITER=12. Might be okay. If slow, user not measuring.
Alternative: Use a simpler Eulerian MAC? Stable fluids fine.
We should implement lin_solve with Gauss-Seidel. Need to handle boundaries. Use single array with index. For boundary set_bnd after each solve.
Index: `const IX = (i,j) => i + (w+2)*j;` but inline for speed in loops. Use `const size=(w+2)*(h+2)`. Loop `for (let j=1;j<=h;j++){ let idx=j*(w+2)+1; for(let i=1;i<=w;i++, idx++){ ... } }`.
Implement set_bnd(b,x):
- For i=1..w: x[IX(i,0)] = b==2 ? -x[IX(i,1)] : x[IX(i,1)]; x[IX(i,h+1)] = b==2 ? -x[IX(i,h)] : x[IX(i,h)];
- For j=1..h: x[IX(0,j)] = b==1 ? -x[IX(1,j)] : x[IX(1,j)]; x[IX(w+1,j)] = b==1 ? -x[IX(w,j)] : x[IX(w,j)];
- corners: average of neighbors.
Implement project(u,v,p,div):
- For interior compute div = -0.5*(u[i+1,j]-u[i-1,j]+v[i,j+1]-v[i,j-1])/h? Here `h` is 1/N. In Stam code using grid size, `div = -0.5*h*(u[i+1,j]-u[i-1,j]+v[i,j+1]-v[i,j-1])`. With w,h dimensions, use `const h = 1/Math.max(w,h)` maybe. But for non-square? Use 1/w for x? Let's use `const h = 1/Math.min(w,h)`; p=0; set_bnd(0,div); set_bnd(0,p); lin_solve(0,p,div,1,4,iter). Then u[i,j] -= 0.5*(p[i+1,j]-p[i-1,j])/h; v[i,j] -= 0.5*(p[i,j+1]-p[i,j-1])/h; set_bnd(1,u); set_bnd(2,v). Note sign/h. Using `h = 1/Math.max(w,h)` yields correct scaling. Actually in Stam code, `h = 1/N` where N is grid size. Use `N = Math.max(w,h)`. Since grid not square? For rectangle, use `N = Math.max(w,h)`. Hmm, if w=160,h=120, h=1/160. But velocities are in grid units per time. Fine.
Need to be careful with scaling to avoid divergence residual. Use `N = Math.max(w,h)` for `h`.
Parameters:
- viscosity: 0.000001? The diffusion rate for velocity. In Stam formula a = dt*visc*N*N. If visc small, negligible. We want turbulent low viscosity. Set visc = 0.000001? But with dt=0.1,N=160 => a=0.00256, diffuse minimal. Could set 0.00001. But if very small, lin_solve c=1+4a ~1.005, okay. Actually no need diffuse velocity if negligible, but stable fluids includes.
- diff for density: 0.00005? a=dt*diff*N*N = 0.1*0.00005*25600=0.128. c=1+4a=1.512. Good. It will diffuse. Edges wispy due to advection and diffusion.
- Dissipation: density array multiply by 0.998 each frame? That plus advection will fade. But we also need not lose too soon. Add fade 0.999.
Injection details:
- We'll have a `time` frame counter. From t=0 to t=15, add density blob. At t=0 add a strong velocity impulse.
- Add density: for all cells, `dens += blob * amount` where blob = exp(-r^2/(2 sigma^2)), sigma maybe 6-10. Add at center.
- Add velocity impulse: outward radial from center + fractal noise. For r < radius, `u += dx/r * strength * falloff + noiseU * noiseScale`; `v += dy/r * strength * falloff + noiseV * noiseScale`. Also add a small downward bias? Ink drop entering water: from top center downward maybe. But for central drop, outward radial burst more dramatic. Use radial outward + swirling noise. Also initial push upward? Actually a drop hitting water surface spreads outward. We'll use outward with some downward/upward random.
- Noise field: Precompute arrays `noiseU`, `noiseV` same size or compute per injection. We can compute on the fly within injection loop. Use noise scale `noiseAmp * Math.sin...` with octaves. For performance, maybe precompute at init. But noise only used for injection, not each frame? Could also add small noise each frame to maintain turbulence. Let's precompute noise arrays for whole grid and add during injection with radial mask.
- Maybe also use `curlNoise(x,y)` each frame and add a tiny vorticity confinement to velocity field to keep swirls. Could add a noise force to all field continuously scaled low. But might be unstable. We'll keep initial noise and velocity self-advection. However diffusion will dampen fine detail. To maintain detail as it expands, we can add small random noise to velocity near density each frame (e.g., force = density * noise * 0.02). That creates fractal tendrils. But stable fluids projection will handle divergence. This might keep swirling. We can do for some frames. Simpler: add a continuous low-amplitude curl noise force everywhere. Might create perpetual turbulence but not too much. But we want a single drop dissipating. Maybe add only near dense regions for early frames.
- To create "ink drop into water" with a cup, maybe add a slight downward flow initially. Let's set drop at top center (cx=W/2, cy=H*0.35) to mimic drop entering from top. Then initial velocity downward and outward. But the prompt says "ink drop into a cup of clear water" and "top soft lighting". Positioning drop near top makes sense. However the visual of diffusion in cup: drop from top and sinks, then spreads. Let's place injection at center but slightly above center. Add downward velocity and outward horizontal.
Let's do injection center `cx = W/2, cy = H*0.4`. Initial velocity: radial outward plus noise. The vertical component will be downward due to center above? Actually radial outward from center includes downward component below center. Since center at 0.4, below is 0.6 of height. Good.
Cup boundary: We'll define a circular region centered at (cx, H*0.55) with radius R = min(W,H)*0.45. Set density=0 and velocity=0 outside this region each frame (mask). This simulates walls. For visual, draw a circular background and maybe draw an outline. But grid mask will confine ink.
Let's implement mask in set_bnd? Instead, after each simulation step, loop over all cells and if outside circle, zero density and velocity. Use squared distance. But that creates hard walls; we should enforce boundary conditions earlier. Simpler: rectangular domain and just let ink hit walls. But cup shape nicer. We can define circular domain with radius R and center (cx,cy). For boundary condition, set fields zero outside circle. For projection, set walls no-slip; hard mask after advection will leak? Could be okay for visual. To avoid artifacts, we can set boundary cells outside as walls. Let's skip complex cup mask and use rectangular. But prompt says "一杯清水". We can render cup visually; the physics domain can be rectangular. Fine.
Visual cup: draw radial gradient background on display canvas: from center lighter to edges darker (glass). Or fill a vertical gradient. Top lighting overlay.
Rendering details:
- Display canvas size = window innerWidth/innerHeight. Use CSS full screen. Sim canvas width W, height H.
- Each frame:
1. displayCtx.globalCompositeOperation = 'source-over'; fill background gradient (water). Maybe a cup shape: radial gradient centered bottom? Use createRadialGradient center bottom, radius min.
2. drawImage simCanvas, 0,0, display.width, display.height.
3. Top light overlay: `displayCtx.globalCompositeOperation = 'source-atop';` create linear gradient from top white alpha 0.35 to transparent; fillRect. This lights ink.
4. Maybe also add a subtle bottom vignette.
Wait `source-atop` after drawing background + ink means it will only draw on existing ink and background? Since background is opaque, source-atop will draw on everything (because destination alpha is full). That would lighten top of water too. Maybe okay as top lighting. But if we want only ink, we need separate alpha. Since background is opaque, can't differentiate. Actually after background fill, destination alpha = 1 everywhere. Source-atop draws on entire canvas. To limit to ink, we could not fill background in canvas, set CSS background image? Or render water background behind canvas via CSS and keep canvas transparent for background. Then `source-atop` only affects ink. Good approach: set canvas background transparent; use CSS body background for water (gradient). Then `source-atop` overlay only on ink. But we also might want to draw some water shading on canvas (subtle radial gradient with low alpha) before ink. That would also be affected by source-atop. We can draw water shading with `globalCompositeOperation` source-over then ink. If water shading is not fully opaque, source-atop will affect it. But if low alpha, fine. However top lighting over water is okay.
Simpler: Use CSS `body` background radial gradient for cup. Canvas transparent background. Render ink only. Then top light source-atop only affects ink (plus maybe transparent areas no effect). This works if canvas background transparent. Use `displayCtx.clearRect` each frame.
But CSS background will be visible through canvas. Good. We can also draw a subtle water shading overlay on canvas with `globalAlpha` 0.2 and not worry. Let's do CSS body background: dark center? Actually cup of water with top lighting: background gradient radial from `#e8f4ff` center to `#b0c4d8` edges. Then ink black over.
However when using drawImage sim canvas, the sim canvas has alpha channel for ink. Good.
Lighting overlay: `displayCtx.globalCompositeOperation = 'source-atop';` fill gradient from top `rgba(255,255,255,0.45)` to bottom `rgba(255,255,255,0)`. This simulates light hitting top of ink. Then reset composite.
Also add `shadowBlur`? Not needed.
Density to pixel mapping:
- For each cell (i,j) interior 1..W,1..H, compute `d = dens[idx]`. `a = 1 - Math.exp(-d * exposure)`. exposure maybe 2.0? Need deep core. Since density values can be high (injection total maybe 1 per cell * many frames). But advection spreads; typical densities 0.01-0.5. Exposure = 8? We'll calibrate. At injection max d maybe 5.0; a~1. Use exposure=10. Edges d=0.05 => a~0.39. Good.
- Set pixel: `data[idx*4]=0; g=0; b=0; a*255`. But note idx includes boundary? For ImageData of W*H, we map only interior cells. Use loop j 0..H-1, i 0..W-1, field index `fi = j*(W+2)+i` maybe if interior start at 1. Simpler map: field array includes boundaries; for pixel (i,j) i in [0,W-1], j in [0,H-1], field index = (j+1)*(W+2)+(i+1). Use that.
- Add a slight blue tint? Black ink in water appears black. Keep black.
Maybe use a color map from deep black to dark gray at edges? But black with alpha gives gray when over light background. Good.
Need to trigger an initial drop. We'll call `addInk()` at start and maybe auto-restart after it dissipates. User didn't ask for restart, but a continuous animation or one-time? It might be nice to loop: after fade, trigger new drop every ~5 seconds. But prompt says "动画" likely continuous. Let's implement a loop: if average density below threshold or timer > 400 frames, reset and drop again. This keeps animation running. Could also let it dissipate and stop. Continuous ink drop is more engaging. We'll implement `phase` based on frame: every 500 frames reset velocity/density and drop. But resetting abruptly might look bad. We can fade out and then inject. Or continuous injection every ~150 frames small drop. Let's create a single large drop at start and periodic small drops. Simpler: at `frame=0` add big drop; then every 400 frames add another drop. That creates repeated drops. We'll also continuously dissipate.
Need to add ink injection function. It takes cx,cy, intensity. It loops through grid near radius. For performance, loop whole grid but check distance. Grid is small. We'll compute noise on the fly? Better precompute `curlU`, `curlV` arrays at initialization. Then injection uses those arrays scaled by mask.
Precompute noise:
```
for (let j=1; j<=H; j++) {
for (let i=1; i<=W; i++) {
let x = i/W, y = j/H;
let u=0,v=0;
let freq=1,amp=1;
for(let o=0;o<5;o++){
let s = Math.sin(freq*x*Math.PI*2 + phaseU[o]);
let c = Math.cos(freq*y*Math.PI*2 + phaseV[o]);
u += amp * s * c;
v += -amp * Math.cos(freq*x*Math.PI*2 + phaseU[o]) * Math.sin(freq*y*Math.PI*2 + phaseV[o]);
freq*=2; amp*=0.5;
}
noiseU[idx]=u; noiseV[idx]=v;
}
}
```
This divergence-free for equal frequencies. Good.
Injection:
```
function inject(cx,cy,frames) {
for each cell:
let dx = i-cx, dy=j-cy, r2=dx*dx+dy*dy;
let blob = Math.exp(-r2/(2*sigma*sigma));
d[idx] += blob * densityRate;
if (r2>0) {
let r = Math.sqrt(r2);
let falloff = Math.exp(-r2/(2*spread*spread));
u[idx] += (dx/r)*outwardStr*falloff + noiseU[idx]*noiseStr*falloff;
v[idx] += (dy/r)*outwardStr*falloff + noiseV[idx]*noiseStr*falloff;
// add downward bias maybe
v[idx] += downwardStr*falloff;
}
}
```
Parameter values: sigma ~ 5, spread ~ 25, outwardStr ~ 80? Because dt small. But too high velocities cause numerical diffusion. For stable fluids, velocities should be < grid spacing per step (N*dt?) Actually CFL: velocity*dt < 1 cell. If dt=0.1, velocities <10. OutwardStr 5-10. noiseStr 3-6. DensityRate maybe 1.0 per injection frame. Injection over 10 frames yields max density ~10. Exposure 0.5? Let's calibrate.
Maybe use dt=0.2. To get visible movement, velocities ~2-5. outwardStr=8.0, noiseStr=5.0. downwardStr=3. densityRate=0.8. Over 15 frames, central density ~12. Exposure 0.6 => a near 1. Good.
But adding huge impulses can break projection. It will project and smooth. Fine.
We need to ensure injection area not too small to avoid all detail collapsing. Use blob sigma=4.5, noise spread=30.
Maybe add a "splash" initial downward jet at t=0: v large downward at center. Then outward.
Continuous small turbulence: We can add to velocity field each frame a low amplitude curl noise weighted by density gradient? But keep simple: after injection, self-advection preserves noise. However to sustain fractal tendrils as it expands, add small noise force to all cells for first 100 frames scaled by `1/(1+frame)`. Could add `vorticity confinement` which is standard in fluid sim to amplify swirling. Implementing vorticity confinement adds detail. But code complexity. Let's add a simple "turbulence force" each frame: For all interior cells, `u[idx] += noiseU[idx]*turbStrength*decay`; `v += noiseV[idx]*turbStrength*decay;` where turbStrength=0.02 and decay = exp(-frame/150). This keeps slow swirling. It is divergence-free-ish but projection will correct. Good.
But noise array frequencies in normalized domain; at grid scale amplitude may be small. Use noiseStr scaled.
Alternatively add random noise in velocity each frame to high density region. Use same noise arrays but multiply by density. `u[idx] += noiseU[idx]*0.01*d[idx]`. That creates detail where ink is. Might be good. But if density high, can add energy. We'll add `vorticity` via adding noise proportional to density with a small coefficient and fade.
Let's decide: In `addForces(frame)` each step, after injection period, add `turb = 0.015 * Math.exp(-frame*0.01)` to velocities using precomputed noise. This maintains detail. Also add slight buoyancy? Ink is heavier, so maybe sink (positive v). Add downward body force proportional to density: `v += 0.05*d`. This makes ink sink and spread. Nice.
So each frame:
- `addSource(u, u_prev, dt)`? Actually add forces directly to u/v arrays before step. The standard step: we add sources to u/v/dens, then diffuse, project, advect, project. We can incorporate injection and forces before diffuse.
Let's code step similar to Stam:
```
function step() {
// velocity step
addSource(u, u0, dt); // but we add sources directly
diffuse(1, u0, u, visc, dt);
diffuse(2, v0, v, visc, dt);
project(u0, v0, u, v);
advect(1, u, u0, u0, v0, dt);
advect(2, v, v0, u0, v0, dt);
project(u, v, u0, v0);
// density step
addSource(dens, dens_prev, dt);
diffuse(0, dens0, dens, diff, dt);
advect(0, dens, dens0, u, v, dt);
fade(dens, 0.999);
}
```
But Stam uses temporary arrays swapped. We have `u_prev`, `v_prev`, `dens_prev`. We can add forces to `u_prev`/`v_prev` then step. Simpler: at beginning of step, zero prev arrays, add injection into prev, then `u += prev*dt`? Actually `addSource(x, s, dt)` does `x += dt*s`. We'll implement:
```
function add_source(x, s, dt) { for all interior x[idx] += dt*s[idx]; }
```
In `inject`, we fill `u_prev`, `v_prev`, `dens_prev` arrays with impulses (zero before). Then step uses them.
We also need continuous forces (buoyancy, turbulence). We can add them to `u_prev`/`v_prev` each frame.
Implementation sequence:
```
function step() {
// add forces to prev arrays
zero(u_prev); zero(v_prev); zero(dens_prev);
applyForces(frameCount); // fills prev arrays
add_source(u, u_prev, dt);
add_source(v, v_prev, dt);
add_source(dens, dens_prev, dt);
// velocity step
diffuse(1, u0, u, visc, dt); // u0 = result, u = previous
// Wait standard: diffuse(b, x, x0, diff, dt) solves x = x0 + a*laplacian(x). So x0 is current u. Then x becomes diffused.
diffuse(2, v0, v, visc, dt);
project(u0, v0, u, v); // project u0,v0 using u,v as temp
advect(1, u, u0, u0, v0, dt);
advect(2, v, v0, u0, v0, dt);
project(u, v, u0, v0);
// density step
diffuse(0, dens0, dens, diff, dt);
advect(0, dens, dens0, u, v, dt);
fade(dens, 0.998);
}
```
Need to track temporary arrays. Let's name:
- u, v current velocities.
- u_prev, v_prev for sources.
- u0, v0 for diffusion result, then used as input to advect; after advect result goes back into u,v. Actually standard code:
```
diffuse (N, u, u0, visc, dt);
SWAP(u0, u); // now u0 holds diffused, u is scratch
project(u0, v0, u, v); // project u0,v0
advect(1, u, u0, u0, v0, dt); // u = advected(u0)
advect(2, v, v0, u0, v0, dt);
project(u, v, u0, v0);
```
Wait after diffuse, result stored in u (first param). Then we need to advect from diffused field to get new velocities. In Stam code:
```
void vel_step (...)
{
add_source (u, u0, dt); add_source (v, v0, dt);
SWAP (u0, u); diffuse (N, 1, u, u0, visc, dt);
SWAP (v0, v); diffuse (N, 2, v, v0, visc, dt);
project (N, u, v, u0, v0);
SWAP (u0, u); SWAP (v0, v);
advect (N, 1, u, u0, u0, v0, dt); advect (N, 2, v, v0, u0, v0, dt);
project (N, u, v, u0, v0);
}
```
After diffuse, u contains diffused, v contains diffused. project uses u,v and u0,v0 as temps. After project, u,v are divergence-free. Then SWAP to put projected into u0,v0; advect results into u,v. Then project again.
Density step:
```
add_source(dens, dens_prev, dt);
SWAP(dens_prev, dens); diffuse(... dens, dens_prev, ...);
SWAP(dens_prev, dens); advect(... dens, dens_prev, u, v, dt);
```
Using scratch arrays.
We can implement `swap(a,b)` by swapping references (return new pair). Since arrays are typed, swapping references is fine.
Let's define arrays:
```
let u = new Float32Array(size), v = new Float32Array(size);
let u_prev = new Float32Array(size), v_prev = new Float32Array(size);
let dens = new Float32Array(size), dens_prev = new Float32Array(size);
```
And `tmp` for diffusion? Actually we can reuse dens_prev etc. Let's follow Stam names with `u0` etc. We'll have:
- u,v current velocities after step.
- u0,v0 used as prev/source/scratch.
Velocity step function:
```
function vel_step() {
add_source(u, u_prev, dt);
add_source(v, v_prev, dt);
[u, u0] = [u0, u]; // now u = diffused result target, u0 = previous (with sources)
diffuse(1, u, u0, visc, dt);
[v, v0] = [v0, v];
diffuse(2, v, v0, visc, dt);
project(u, v, u0, v0); // u,v projected
[u, u0] = [u0, u];
[v, v0] = [v0, v];
advect(1, u, u0, u0, v0, dt);
advect(2, v, v0, u0, v0, dt);
project(u, v, u0, v0);
}
```
But after first swap, `u` becomes old `u0` (initially zero maybe). Then `diffuse(1, u, u0,...)` uses `u0` as source and `u` as target. Need ensure `u0` initially defined as scratch. Let's initialize u0 = new Float32Array(size), v0 similar. Then each step: add source to u/v. Then swap. Good.
But the initial swap loses current velocities? Actually after add_source, u holds current velocities plus source. We swap so u0 holds that, u is scratch target. diffuse writes target u. Good.
After diffuse, u contains diffused velocities; v contains diffused velocities. project modifies them in place using u0/v0 as temp. Then swap to use u0/v0 as input to advect, u/v as output. Good.
Density step:
```
function dens_step() {
add_source(dens, dens_prev, dt);
[dens, dens0] = [dens0, dens];
diffuse(0, dens, dens0, diff, dt);
[dens, dens0] = [dens0, dens];
advect(0, dens, dens0, u, v, dt);
}
```
Need `dens0` scratch.
At end, fade dens.
Now `applyForces(frame)` fills `u_prev`, `v_prev`, `dens_prev` arrays. We zero them each frame. We also need `add_source` to multiply by dt. So impulses should be per unit time. We'll set values accordingly.
Alternatively, to avoid zeroing and dt scaling, we can directly add to u/v/dens in `applyForces` and skip add_source. But standard approach uses dt. Let's keep add_source. So injection values are rates per second? dt. For 60fps, dt maybe 0.1 or 0.2; injection rate * dt. Set rates high enough.
Injection specifics in `applyForces`:
- Determine if in injection window for each drop. We'll have global `frameCount`, `dropFrame` maybe `frameCount % dropInterval`. For first 15 frames after drop start, inject.
- At start, maybe set `dropStart = 0`.
- `const t = frameCount - dropStart; if (t >=0 && t < injectFrames) { ... }`
- Compute `cx = W/2 + (Math.random()-0.5)*2` maybe fixed.
- `cy = H*0.38`.
- Loop over cells. Use field index `idx`.
- `let i = idx % (W+2); let j = Math.floor(idx/(W+2));` This for all size including boundaries. We can skip boundaries. Use loop j=1..H, i=1..W.
- `dx = i-cx; dy = j-cy; r2 = dx*dx+dy*dy;`
- density contribution: `dens_prev[idx] += Math.exp(-r2/(2*sigmaD*sigmaD)) * densRate;`
- velocity contribution: radial outward plus noise plus downward. `let r = Math.sqrt(r2); let falloff = Math.exp(-r2/(2*spreadV*spreadV)); if (r>0.1) { u_prev[idx] += (dx/r)*outwardRate*falloff + noiseU[idx]*noiseRate*falloff; v_prev[idx] += (dy/r)*outwardRate*falloff + noiseV[idx]*noiseRate*falloff; } v_prev[idx] += downwardRate*falloff;`
- Note: `densRate` is rate per second (divide by dt maybe). Since add_source multiplies by dt. We'll tune.
Continuous turbulence:
- `let turb = turbRate * Math.exp(-t*decay);` for all cells after injection maybe.
- `u_prev[idx] += noiseU[idx]*turb; v_prev[idx] += noiseV[idx]*turb;`
- Buoyancy: `v_prev[idx] += dens[idx] * buoyancy;` (ink sinks, positive y). Since density in current dens. This should be added as force. Use `dens` current.
Frame resetting:
- If `frameCount - dropStart > 500` or average density low, start new drop. To start new drop, set `dropStart = frameCount;` optionally clear velocities/density partially. If we don't clear, new drop will overlay. Could be fine. But to avoid infinite accumulation, we can gradually fade. We'll just start injecting again; existing density will dissipate. Or do a hard reset when density low. Let's do: every 450 frames, set `dropStart = frameCount;` and also reduce existing density by half? It might create a new drop while previous haze remains. Good.
- At reset, set velocities to zero? No, keep ambient water motion? We can scale velocities by 0.2 to calm before new drop.
Need to compute average density maybe. Not necessary; just time-based.
Let's set dropInterval=500, injectFrames=15.
Parameter values: W=180,H=140, dt=0.15, diff=0.00005, visc=0.000001, fade=0.9995.
densRate = 12? With dt=0.15, per frame per center cell add 12*exp(0)=1.8. Over 15 frames -> central ~27. Exposure 0.2 => a~1. Good.
outwardRate = 40? With dt=0.15 => velocity 6. Could be okay. noiseRate = 25 => 3.75. downwardRate = 30 => 4.5.
spreadV = 22. sigmaD=5.
turbRate = 8? dt=0.15 => 1.2 per frame noise; could be high. Use turbRate=3.
buoyancy = 0.5? dt => 0.075 per density per frame. If density 1, small.
We need to test mentally: velocities up to ~10. dt=0.15 => advection backtrace distance 1.5 cells. Fine, stable.
Diffusion with diff=0.00005, N=180 => a=dt*diff*N*N = 0.15*0.00005*32400=0.243. c=1+4a=1.972. Good.
Visc a = 0.15*0.000001*32400=0.00486; c~1.019. Low.
Projection iterations: 16 maybe to get good divergence-free. 12 maybe. Let's set 12. For 180x140 grid, each iteration loop 25k, 12* maybe 300k; project does 2 solves? project uses lin_solve once (12 iter) plus 2 loops; per velocity step two projects => 24 iter loops. Fine.
Potential issue: Using `Math.exp` per cell in render (25k) each frame okay. Also in injection for 15 frames. Fine.
Now rendering with putImageData:
- Need `simData` ImageData `new ImageData(W,H)`. `const simCtx = simCanvas.getContext('2d')`.
- `const ddata = simData.data;`
- Loop j 0..H-1, i 0..W-1:
```
let fi = (j+1)*(W+2)+(i+1);
let dens = density[fi];
let a = 1 - Math.exp(-dens * exposure);
if (a < 0.001) { idx+=4; continue; }
ddata[idx] = 0; ddata[idx+1] = 0; ddata[idx+2] = 0; ddata[idx+3] = Math.floor(a*255);
idx+=4;
```
- `simCtx.putImageData(simData,0,0);`
- `displayCtx.drawImage(simCanvas,0,0,displayCanvas.width,displayCanvas.height);`
Exposure: Let's compute expected max. Injection center per frame: densRate=12, dt=0.15 => 1.8 per frame, 15 frames => ~27 at center (plus diffusion). With exposure=0.08, a=1-exp(-2.16)=0.885. At core enough. Edge d maybe 0.2 => a=1-exp(-0.016)=0.016. Very wispy. Good. Use exposure=0.08.
Maybe add a `gamma` or exponent: `a = Math.pow(1-Math.exp(-dens*exposure), 0.7)` to make edges more visible. Use simple.
Lighting overlay:
```
displayCtx.globalCompositeOperation = 'source-atop';
let grad = displayCtx.createLinearGradient(0,0,0,displayCanvas.height);
grad.addColorStop(0, 'rgba(255,255,255,0.45)');
grad.addColorStop(0.5, 'rgba(255,255,255,0.08)');
grad.addColorStop(1, 'rgba(255,255,255,0)');
displayCtx.fillStyle = grad;
displayCtx.fillRect(0,0,displayCanvas.width, displayCanvas.height);
displayCtx.globalCompositeOperation = 'source-over';
```
This only affects ink because background is transparent. Good.
Background: Use CSS body background. Create cup-like gradient. For example:
```
body {
margin:0; overflow:hidden;
background: radial-gradient(ellipse at 50% 60%, #e8f7ff 0%, #cfe6f7 40%, #9bbbd1 100%);
}
```
Maybe also canvas CSS to center: `position: absolute; top:0; left:0; width:100%; height:100%;`
But note `drawImage` from sim canvas with smoothing, the ink edges smooth. Good.
Maybe add subtle "meniscus" or top highlight? Not necessary.
Potential issue: Canvas CSS size vs attribute size. We'll set displayCanvas width/height to window innerWidth/height. On resize, update.
Resize handling: reinit? Sim grid fixed; just update display canvas size. CSS 100% will handle. For high-DPI, maybe set attributes to window dimensions; not scaling DPR. Fine.
Need to ensure code is all in one HTML, complete. No external libs. Use `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Ink Drop</title><style>...</style></head><body><canvas id="c"></canvas><script>...</script></body></html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>墨滴入水</title>
<style>
html,body{margin:0;height:100%;overflow:hidden;background:radial-gradient(ellipse at 50% 55%, #eef9ff 0%, #d4ecfb 38%, #a7c9de 100%);}
canvas{display:block;position:absolute;inset:0;width:100%;height:100%;}
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
const W=200, H=150;
const size=(W+2)*(H+2);
const N=Math.max(W,H);
const dt=0.08;
const diff=0.00005;
const visc=0.000001;
const iter=14;
const h=1/N;
let u=new Float32Array(size), v=new Float32Array(size);
let u0=new Float32Array(size), v0=new Float32Array(size);
let uPrev=new Float32Array(size), vPrev=new Float32Array(size);
let dens=new Float32Array(size), dens0=new Float32Array(size), densPrev=new Float32Array(size);
const noiseU=new Float32Array(size), noiseV=new Float32Array(size);
const phases=new Float32Array(10);
for(let k=0;k<10;k++) phases[k]=Math.random()*6.28318530718;
for(let j=1;j<=H;j++){
const row=j*(W+2);
for(let i=1;i<=W;i++){
let x=i/W, y=j/H;
let nu=0, nv=0, f=1, amp=1;
for(let o=0;o<5;o++){
const px=phases[o], py=phases[o+5];
const sx=Math.sin(f*x*Math.PI*2+px);
const cx=Math.cos(f*x*Math.PI*2+px);
const sy=Math.sin(f*y*Math.PI*2+py);
const cy=Math.cos(f*y*Math.PI*2+py);
nu+=amp*sx*cy;
nv+=-amp*cx*sy;
f*=2; amp*=0.5;
}
const idx=row+i;
noiseU[idx]=nu;
noiseV[idx]=nv;
}
}
function setBnd(b,x){
for(let i=1;i<=W;i++){
x[i]=b===2?-x[i+(W+2)]:x[i+(W+2)];
x[i+(W+2)*(H+1)]=b===2?-x[i+(W+2)*H]:x[i+(W+2)*H];
}
for(let j=1;j<=H;j++){
const row=j*(W+2);
x[row]=b===1?-x[row+1]:x[row+1];
x[row+W+1]=b===1?-x[row+W]:x[row+W];
}
x[0]=0.5*(x[1]+x[W+2]);
x[W+1]=0.5*(x[W]+x[W+1+(W+2)]);
x[(H+1)*(W+2)]=0.5*(x[(H+1)*(W+2)+1]+x[H*(W+2)]);
x[(H+1)*(W+2)+W+1]=0.5*(x[(H+1)*(W+2)+W]+x[H*(W+2)+W+1]);
}
function linSolve(b,x,x0,a,c){
const cRecip=1/c;
for(let k=0;k<iter;k++){
for(let j=1;j<=H;j++){
const row=j*(W+2);
for(let i=1;i<=W;i++){
const idx=row+i;
x[idx]=(x0[idx]+a*(x[idx-1]+x[idx+1]+x[idx-(W+2)]+x[idx+(W+2)]))*cRecip;
}
}
setBnd(b,x);
}
}
function diffuse(b,x,x0,diffusion){
const a=dt*diffusion*N*N;
linSolve(b,x,x0,a,1+4*a);
}
function advect(b,d,d0,u,v){
const dt0=dt*N;
for(let j=1;j<=H;j++){
const row=j*(W+2);
for(let i=1;i<=W;i++){
const idx=row+i;
let x=i-dt0*u[idx];
let y=j-dt0*v[idx];
if(x<0.5)x=0.5; if(x>W+0.5)x=W+0.5;
if(y<0.5)y=0.5; if(y>H+0.5)y=H+0.5;
const i0=Math.floor(x), i1=i0+1;
const j0=Math.floor(y), j1=j0+1;
const s1=x-i0, s0=1-s1;
const t1=y-j0, t0=1-t1;
const r0=j0*(W+2), r1=j1*(W+2);
d[idx]=s0*(t0*d0[r0+i0]+t1*d0[r1+i0])+s1*(t0*d0[r0+i1]+t1*d0[r1+i1]);
}
}
setBnd(b,d);
}
function project(u,v,p,div){
for(let j=1;j<=H;j++){
const row=j*(W+2);
for(let i=1;i<=W;i++){
const idx=row+i;
div[idx]=-0.5*h*(u[idx+1]-u[idx-1]+v[idx+(W+2)]-v[idx-(W+2)]);
p[idx]=0;
}
}
setBnd(0,div); setBnd(0,p);
linSolve(0,p,div,1,4);
for(let j=1;j<=H;j++){
const row=j*(W+2);
for(let i=1;i<=W;i++){
const idx=row+i;
u[idx]-=0.5*(p[idx+1]-p[idx-1])/h;
v[idx]-=0.5*(p[idx+(W+2)]-p[idx-(W+2)])/h;
}
}
setBnd(1,u); setBnd(2,v);
}
function addSource(x,s){
for(let i=0;i<size;i++) x[i]+=dt*s[i];
}
const densRate=80, outwardRate=7, noiseRate=5, downwardRate=6;
const turbRate=2, buoyancy=4;
const sigmaD=4.5, spreadV=22;
const injectFrames=12;
let cx=W/2, cy=H*0.36, dropStart=0, frame=0;
function startDrop(){
dropStart=frame;
cx=W*0.5+(Math.random()-0.5)*W*0.1;
cy=H*0.34+(Math.random()-0.5)*H*0.06;
for(let i=0;i<size;i++){
u[i]*=0.12; v[i]*=0.12; dens[i]*=0.18;
}
}
function applyForces(){
for(let i=0;i<size;i++){ uPrev[i]=0; vPrev[i]=0; densPrev[i]=0; }
const t=frame-dropStart;
if(t>=0 && t<injectFrames){
const invSigD=1/(sigmaD*sigmaD), invSpread=1/(spreadV*spreadV);
const vBurst=Math.exp(-t*0.6);
for(let j=1;j<=H;j++){
const row=j*(W+2);
for(let i=1;i<=W;i++){
const idx=row+i;
const dx=i-cx, dy=j-cy, r2=dx*dx+dy*dy;
densPrev[idx]+=Math.exp(-r2*invSigD*0.5)*densRate;
const falloff=Math.exp(-r2*invSpread*0.5);
if(r2>0.5){
const r=Math.sqrt(r2);
const nx=dx/r, ny=dy/r;
uPrev[idx]+=nx*outwardRate*vBurst*falloff+noiseU[idx]*noiseRate*vBurst*falloff;
vPrev[idx]+=ny*outwardRate*vBurst*falloff+noiseV[idx]*noiseRate*vBurst*falloff;
}
vPrev[idx]+=downwardRate*vBurst*falloff;
}
}
}
const turb=turbRate*Math.exp(-t*0.015);
if(turb>0.001){
for(let j=1;j<=H;j++){
const row=j*(W+2);
for(let i=1;i<=W;i++){
const idx=row+i;
uPrev[idx]+=noiseU[idx]*turb;
vPrev[idx]+=noiseV[idx]*turb;
vPrev[idx]+=dens[idx]*buoyancy;
}
}
}
}
function applyCupMask(){
const cxm=W*0.5, cym=H*0.56, R=Math.min(W,H)*0.48, R2=R*R;
for(let j=1;j<=H;j++){
const row=j*(W+2);
for(let i=1;i<=W;i++){
const dx=i-cxm, dy=j-cym;
if(dx*dx+dy*dy>R2) dens[row+i]=0;
}
}
}
function step(){
applyForces();
addSource(u,uPrev);
addSource(v,vPrev);
let tmp;
tmp=u; u=u0; u0=tmp;
diffuse(1,u,u0,visc);
tmp=v; v=v0; v0=tmp;
diffuse(2,v,v0,visc);
project(u,v,u0,v0);
tmp=u; u=u0; u0=tmp;
tmp=v; v=v0; v0=tmp;
advect(1,u,u0,u0,v0);
advect(2,v,v0,u0,v0);
project(u,v,u0,v0);
addSource(dens,densPrev);
tmp=dens; dens=dens0; dens0=tmp;
diffuse(0,dens,dens0,diff);
tmp=dens; dens=dens0; dens0=tmp;
advect(0,dens,dens0,u,v);
for(let i=0;i<size;i++) dens[i]*=0.9988;
applyCupMask();
}
const canvas=document.getElementById('c');
const ctx=canvas.getContext('2d');
const simCanvas=document.createElement('canvas');
simCanvas.width=W; simCanvas.height=H;
const simCtx=simCanvas.getContext('2d');
const simData=simCtx.createImageData(W,H);
const exposure=0.035;
function render(){
const data=simData.data;
let p=0;
for(let j=0;j<H;j++){
const row=(j+1)*(W+2)+1;
for(let i=0;i<W;i++){
const idx=row+i;
let d=dens[idx];
if(d<0) d=0;
let a=d*exposure;
if(a>0) a=1-Math.exp(-a);
if(a<0.003){
data[p+3]=0;
}else{
data[p]=0; data[p+1]=0; data[p+2]=0;
data[p+3]=Math.min(255, Math.floor(a*255));
}
p+=4;
}
}
simCtx.putImageData(simData,0,0);
ctx.clearRect(0,0,canvas.width,canvas.height);
ctx.imageSmoothingEnabled=true;
ctx.drawImage(simCanvas,0,0,canvas.width,canvas.height);
ctx.globalCompositeOperation='source-atop';
const g=ctx.createLinearGradient(0,0,0,canvas.height);
g.addColorStop(0,'rgba(255,255,255,0.42)');
g.addColorStop(0.35,'rgba(255,255,255,0.10)');
g.addColorStop(1,'rgba(255,255,255,0)');
ctx.fillStyle=g;
ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.globalCompositeOperation='source-over';
}
function resize(){ canvas.width=window.innerWidth; canvas.height=window.innerHeight; }
window.addEventListener('resize',resize); resize();
function loop(){
if(frame-dropStart>720) startDrop();
step();
render();
frame++;
requestAnimationFrame(loop);
}
startDrop();
requestAnimationFrame(loop);
</script>
</body>
</html>
1.37s
TTFT
200
Reasoning TPS
132s · ≈26,414 tok
234
Output TPS
9.66s · ≈2,258 tok
28,672
Total Tokens (official)
Input 120
Elapsed 145s · Average 202 tok/s · Peak 900 tok/s · Cost ≈$0.115
智谱 GLM-5.1
glm-5.1
用户想要一个单文件HTML,用原生Canvas实现墨水滴入清水后扩散的流体动画。要求:
1. 墨水入水瞬间向外绽放湍流、翻涌的羽状墨团
2. 分形的卷须不断弯曲、分叉地铺开
3. 缓慢消散成淡淡的云雾
4. 真实的流体运动(平流与扩散)
5. 墨水要有密度变化:深浓内核、丝缕状半透明边缘
6. 顶部柔和打光
7. 60fps
8. 不使用外部库
让我思考如何实现:
**核心技术方案:**
我会使用一个简化的流体模拟,使用网格来存储墨水浓度和速度场。
**方法选择:**
1. **完整的Navier-Stokes求解器** - 太复杂,性能差
2. **反应扩散方程** - 可以但慢
3. **简化的扩散-平流模型 + 粒子系统** - 最佳平衡
4. **纯粒子系统** - 不够真实
我决定采用混合方案:
- **底层流体网格**:使用密度场 + 速度场,进行平流和扩散
- **粒子系统**:在网格上叠加大量粒子,用于渲染丝缕状细节
- **基于物理的密度梯度**:让卷须自然分叉
具体实现:
**网格方法:**
- 创建一个密度网格 d[x][y]
- 创建一个速度场 vx[x][y], vy[x][y]
- 每帧:
1. 施加力(初始注入、湍流扰动)
2. 平流速度
3. 平流密度
4. 扩散密度
5. 渲染
**粒子方法(更高级):**
- 大量粒子,每个粒子有:位置、速度、浓度、生命周期
- 粒子受到:
- 中心向外的初始爆发
- Curl noise(无散度噪声场)形成的湍流
- 自身密度引起的扩散
- 渲染:用径向渐变,深度按浓度
我决定用 **粒子 + 网格密度场** 的组合:
**关键技术细节:**
1. **Curl Noise** - 生成无散度的湍流场,让墨水看起来像真正的流体
2. **粒子渲染** - 每个粒子是一个软光斑,多个粒子叠加形成墨团
3. **分叉效果** - 通过密度的局部梯度实现,密度高的地方粒子会被"推开"
让我开始写代码:
```javascript
// 主要参数
const PARTICLE_COUNT = 1500;
const CURL_SCALE = 0.002;
const NOISE_TIME_SCALE = 0.0003;
// 粒子结构
class Particle {
constructor() {
this.x = 0;
this.y = 0;
this.vx = 0;
this.vy = 0;
this.density = 0; // 0-1
this.life = 1;
this.size = 0;
}
}
```
**渲染策略:**
- 使用 `globalCompositeOperation = 'source-over'`
- 每个粒子用径向渐变绘制
- 整体加上水面的环境色和顶部光照
- 容器边缘的反射
**优化:**
- 离屏Canvas用于绘制墨水层
- 主Canvas用于绘制水面背景和合成
- requestAnimationFrame控制帧率
让我设计更详细的方案:
**颜色方案:**
- 水的背景:深青蓝/深灰,模拟一杯清水
- 实际上清水是透明的,但为了显示墨水,背景应该是淡的渐变
- 我用一个深蓝灰色背景(像深色玻璃杯)
- 墨水:纯黑到半透明黑
让我考虑画面构图:
- 一个圆形或矩形区域代表水杯/水的范围
- 中心是墨水滴落的位置
- 顶部有光照
- 可能加上玻璃杯的边缘
**让我开始具体实现:**
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>墨水扩散</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #0a0e14; overflow: hidden; }
body { display: flex; align-items: center; justify-content: center; }
canvas { display: block; }
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script>
// ...
</script>
</body>
</html>
```
**核心算法 - Curl Noise实现:**
```javascript
// 简化的噪声函数
function noise2D(x, y) {
// 使用sin函数组合作为快速噪声
const n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453;
return n - Math.floor(n);
}
// 或者使用SmoothNoise
function smoothNoise(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const a = noise2D(ix, iy);
const b = noise2D(ix + 1, iy);
const c = noise2D(ix, iy + 1);
const d = noise2D(ix + 1, iy + 1);
// 平滑插值
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
return a * (1 - ux) * (1 - uy) +
b * ux * (1 - uy) +
c * (1 - ux) * uy +
d * ux * uy;
}
```
实际上为了更平滑,我应该实现一个Perlin Noise或者Simplex Noise的简化版本。让我使用更好的伪噪声:
```javascript
// 哈希函数
function hash(x, y) {
let h = x * 374761393 + y * 668265263;
h = (h ^ (h >> 13)) * 1274126177;
return ((h ^ (h >> 16)) >>> 0) / 4294967295;
}
// 2D Perlin噪声
function perlin2D(x, y) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const xf = x - xi;
const yf = y - yi;
const u = xf * xf * xf * (xf * (xf * 6 - 15) + 10);
const v = yf * yf * yf * (yf * (yf * 6 - 15) + 10);
const aa = hash(xi, yi);
const ab = hash(xi, yi + 1);
const ba = hash(xi + 1, yi);
const bb = hash(xi + 1, yi + 1);
const x1 = aa + (ba - aa) * u;
const x2 = ab + (bb - ab) * u;
return x1 + (x2 - x1) * v;
}
// Curl noise (无散度)
function curlNoise(x, y, t) {
const eps = 0.01;
const n1 = perlin2D(x, y + eps + t);
const n2 = perlin2D(x, y - eps + t);
const n3 = perlin2D(x + eps, y + t);
const n4 = perlin2D(x - eps, y + t);
const dy = (n1 - n2) / (2 * eps);
const dx = (n3 - n4) / (2 * eps);
return { x: dy, y: -dx }; // 旋转90度形成无散度场
}
```
**完整流程:**
1. 初始化:在中心位置生成大量粒子,赋予向外的初始速度(爆炸式)
2. 每帧:
- 计算每个粒子位置的curl noise速度场
- 混合curl noise速度和粒子自身速度
- 应用一些阻力
- 应用密度扩散(向周围渗透)
- 更新位置
- 绘制:使用径向渐变
3. 墨水团结构:
- 内核:高密度粒子,黑色不透明
- 中层:中密度粒子,深灰色
- 外层:低密度粒子,半透明丝缕
**视觉效果增强:**
1. 使用'lighter'或'screen'混合模式让墨水叠加更真实
2. 实际上墨水是相减的,应该用正常模式或者'source-over'
3. 添加少量模糊或多次绘制以获得更柔的边缘
**让我重新思考:粒子 vs 网格**
考虑到要求:
- 60fps
- 分形卷须
- 真实流体运动
我觉得 **粒子系统 + Curl Noise** 是最好的方案:
- Curl Noise生成的速度场是无散度的(不可压缩流体)
- 大量粒子叠加可以形成连续的墨水团
- 不同密度的粒子可以表现出层次感
- 粒子受到curl noise的推动形成丝缕状结构
**让我开始写完整代码:**
```javascript
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let width, height;
function resize() {
width = canvas.width = window.innerWidth;
height = canvas.height = window.innerHeight;
}
resize();
window.addEventListener('resize', resize);
// 粒子系统
const particles = [];
const MAX_PARTICLES = 2000;
class Particle {
constructor() {
this.reset(0, 0, 0);
}
reset(x, y, intensity) {
this.x = x;
this.y = y;
// 初始爆炸式向外
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * 3 + 1;
this.vx = Math.cos(angle) * speed * intensity;
this.vy = Math.sin(angle) * speed * intensity;
this.life = 1.0;
this.maxLife = Math.random() * 0.5 + 0.5;
this.density = 1.0;
this.size = Math.random() * 3 + 2;
}
update(dt, time) {
// Curl noise
const curl = curlNoise(this.x * 0.003, this.y * 0.003, time * 0.0001);
// 混合速度
this.vx = this.vx * 0.96 + curl.x * 0.4;
this.vy = this.vy * 0.96 + curl.y * 0.4;
// 一些向上的力(如果墨水较轻)
// this.vy -= 0.01;
// 更新位置
this.x += this.vx * dt * 60;
this.y += this.vy * dt * 60;
// 生命衰减
this.life -= dt / this.maxLife;
this.density = this.life * 0.8 + 0.2;
}
draw(ctx) {
if (this.life <= 0) return;
const alpha = Math.max(0, this.life) * this.density;
const size = this.size * (0.5 + this.life * 0.5);
const gradient = ctx.createRadialGradient(this.x, this.y, 0, this.x, this.y, size);
gradient.addColorStop(0, `rgba(8, 10, 14, ${alpha * 0.8})`);
gradient.addColorStop(0.5, `rgba(10, 15, 25, ${alpha * 0.4})`);
gradient.addColorStop(1, `rgba(10, 15, 25, 0)`);
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(this.x, this.y, size, 0, Math.PI * 2);
ctx.fill();
}
}
// 初始化粒子池
for (let i = 0; i < MAX_PARTICLES; i++) {
particles.push(new Particle());
}
// 注入墨水
function dropInk(x, y, count, intensity) {
let injected = 0;
for (let i = 0; i < particles.length && injected < count; i++) {
if (particles[i].life <= 0) {
particles[i].reset(x, y, intensity);
injected++;
}
}
}
// 初始滴入墨水
let lastDrop = 0;
let frameCount = 0;
function animate(time) {
const dt = 1/60;
frameCount++;
// 持续滴入墨水(每隔一段时间)
if (frameCount % 120 === 0) { // 每2秒
dropInk(width/2, height/2, 300, 1.5);
}
// 淡化背景(保留墨迹)
ctx.fillStyle = 'rgba(15, 25, 40, 0.04)';
ctx.fillRect(0, 0, width, height);
// 更新和绘制粒子
ctx.globalCompositeOperation = 'source-over';
for (let p of particles) {
if (p.life > 0) {
p.update(dt, time);
p.draw(ctx);
}
}
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
```
这是基本框架,但还需要:
1. 更好的Curl Noise
2. 更精细的粒子初始化(让初始墨团更紧凑)
3. 渲染的优化(用低分辨率画布叠加)
4. 视觉效果增强(杯壁、光照)
5. 卷须分叉的效果
**关于"分叉"和"分形":**
让粒子按"代"分批释放:
- 初始释放N个粒子(爆发)
- 之后每帧释放一些粒子作为"卷须"
- 卷须从已有墨团的边缘发出,受curl noise影响
或者更好的方法:
- 粒子在密度高的地方有更小的速度变化
- 粒子在密度低的地方移动更快(边缘扩散)
- 这样自然形成丝缕
**最终方案:**
让我重新设计:
1. **背景层**:模拟一杯水的容器,有玻璃杯边缘,顶部光源
2. **墨水层(主层)**:大量粒子叠加,颜色从纯黑到半透明
3. **效果层**:模糊处理,让墨水更柔和
**关键技术:**
**Perlin Noise实现:**
让我用一个更可靠的Perlin Noise:
```javascript
// 改进的Perlin Noise
const perm = new Uint8Array(512);
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
// 洗牌
for (let i = 255; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[p[i], p[j]] = [p[j], p[i]];
}
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + (b - a) * t; }
function grad(hash, x, y) {
const h = hash & 7;
const u = h < 4 ? x : y;
const v = h < 4 ? y : x;
return ((h & 1) ? -u : u) + ((h & 2) ? -2*v : 2*v);
}
function perlin(x, y) {
const X = Math.floor(x) & 255;
const Y = Math.floor(y) & 255;
x -= Math.floor(x);
y -= Math.floor(y);
const u = fade(x);
const v = fade(y);
const A = perm[X] + Y;
const B = perm[X + 1] + Y;
return lerp(
lerp(grad(perm[A], x, y), grad(perm[B], x-1, y), u),
lerp(grad(perm[A+1], x, y-1), grad(perm[B+1], x-1, y-1), u),
v
);
}
```
**Curl Noise:**
```javascript
function curl(x, y, t) {
const e = 0.1;
const n1 = perlin(x, y + e + t);
const n2 = perlin(x, y - e + t);
const n3 = perlin(x + e, y + t);
const n4 = perlin(x - e, y + t);
return {
x: (n1 - n2) / (2 * e),
y: -(n3 - n4) / (2 * e)
};
}
```
**粒子设计 - 分代:**
为了实现"分形卷须",我让粒子有不同的属性:
- 中心粒子:尺寸大,密度高,速度低(内核)
- 边缘粒子:尺寸小,密度低,速度高(卷须)
- 卷须粒子:在主墨团边缘生成,沿着curl noise方向延伸
**实施细节:**
1. 在中心位置释放主墨团(大约300个粒子)
2. 同时释放一些"卷须"粒子,它们沿着curl noise方向的轨迹移动
3. 主墨团的粒子在扩散过程中,会自然形成丝缕状(因为curl noise的方向性)
让我考虑画面构图:
实际上用户说"一杯清水",但我也想要全屏的视觉效果。我会做:
- 全屏深色背景
- 中间一个圆形/圆角矩形代表水面(俯视角度)
- 或者:从侧面看,墨水从上方滴入
我选择 **俯视角度**:墨水从中心扩散到边缘,更明显地展示流体效果。
或者更好的:**3/4视角**(轻微倾斜的俯视),可以同时看到杯子的深度和扩散效果。
不过为了简化,**俯视**最直接,扩散效果也最壮观。
让我做一个圆形的"杯子",墨水在中心扩散。
**完整代码框架:**
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>墨水扩散 · Ink Diffusion</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #050810;
font-family: -apple-system, sans-serif;
}
body {
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(ellipse at center, #1a2540 0%, #050810 80%);
}
#canvas {
display: block;
border-radius: 50%;
box-shadow:
0 0 80px rgba(0,0,0,0.8) inset,
0 0 60px rgba(100,150,200,0.1),
0 30px 100px rgba(0,0,0,0.8);
}
.info {
position: absolute;
top: 20px;
left: 20px;
color: rgba(255,255,255,0.5);
font-size: 12px;
letter-spacing: 2px;
text-transform: uppercase;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div class="info">INK IN WATER · CURL NOISE FLOW</div>
<script>
// ...
</script>
</body>
</html>
```
让我专注于核心流体动画的实现。
**粒子初始化改进:**
让初始墨团形成"羽状"效果:
- 主爆发:300个粒子向四周爆发
- 内核:100个高密度粒子在中心慢慢扩散
- 卷须:100个粒子沿curl noise方向延伸
**绘制顺序:**
由于半透明叠加:
1. 先绘制低密度粒子(背景丝缕)
2. 再绘制高密度粒子(核心)
3. 最后整体应用一个微妙的模糊
或者:直接绘制,依靠混合模式产生叠加效果。
**关于"淡淡的云雾":**
低密度粒子+长时间存活+缓慢消散 = 云雾效果
我会让粒子在生命末期变得很大但很透明,模拟扩散成雾。
**让我整合所有内容,写出最终代码:**
考虑到性能,我会:
- 限制粒子数(1500-2000)
- 使用Object Pool避免GC
- 简化Curl Noise计算
- 使用离屏Canvas进行优化(如果需要)
实际上,让我直接用一个简单但效果好的方法:
**最终算法:**
1. 粒子系统驱动主体墨水
2. 每个粒子有"密度"属性
3. Curl Noise提供流场
4. 粒子大小随密度变化
5. 半透明叠加渲染
让我现在写完整的代码。
```javascript
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d', { alpha: true });
// 离屏画布用于墨水层
const inkCanvas = document.createElement('canvas');
const inkCtx = inkCanvas.getContext('2d');
let W, H, CX, CY;
const RADIUS = 380; // 杯子半径
function resize() {
const size = Math.min(window.innerWidth, window.innerHeight) - 80;
canvas.width = canvas.height = size;
inkCanvas.width = inkCanvas.height = size;
W = size;
H = size;
CX = W / 2;
CY = H / 2;
}
resize();
window.addEventListener('resize', resize);
// Perlin噪声预计算
const perm = new Uint8Array(512);
(function() {
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
for (let i = 255; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[p[i], p[j]] = [p[j], p[i]];
}
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
})();
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + (b - a) * t; }
function grad2(hash, x, y) {
const h = hash & 7;
const u = h < 4 ? x : y;
const v = h < 4 ? y : x;
return ((h & 1) ? -u : u) + ((h & 2) ? -2*v : 2*v);
}
function perlin2(x, y) {
const X = Math.floor(x) & 255;
const Y = Math.floor(y) & 255;
x -= Math.floor(x);
y -= Math.floor(y);
const u = fade(x);
const v = fade(y);
const A = perm[X] + Y;
const B = perm[X + 1] + Y;
return lerp(
lerp(grad2(perm[A], x, y), grad2(perm[B], x-1, y), u),
lerp(grad2(perm[A+1], x, y-1), grad2(perm[B+1], x-1, y-1), u),
v
);
}
function curl(x, y, t) {
const e = 0.3;
const dx = (perlin2(x, y + e + t) - perlin2(x, y - e + t)) / (2 * e);
const dy = -(perlin2(x + e, y + t) - perlin2(x - e, y + t)) / (2 * e);
return [dx, dy];
}
// 粒子类
const PARTICLE_MAX = 2500;
const particles = [];
class Particle {
constructor() {
this.active = false;
}
spawn(x, y, opts = {}) {
this.x = x + (Math.random() - 0.5) * 4;
this.y = y + (Math.random() - 0.5) * 4;
const angle = opts.angle !== undefined ? opts.angle : Math.random() * Math.PI * 2;
const speed = opts.speed !== undefined ? opts.speed : Math.random() * 2 + 1;
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * speed;
this.life = 1.0;
this.maxLife = opts.maxLife || (Math.random() * 4 + 4); // 4-8秒
this.density = opts.density || (Math.random() * 0.5 + 0.5);
this.size = opts.size || (Math.random() * 3 + 2);
this.type = opts.type || 'normal'; // 'core', 'tendril', 'mist'
this.active = true;
}
update(dt, time) {
if (!this.active) return;
// Curl noise力
const scale = 0.012;
const ts = time * 0.00008;
const [cx, cy] = curl(this.x * scale, this.y * scale, ts);
// 应用力(类型不同力度不同)
let forceScale;
if (this.type === 'core') {
forceScale = 0.5;
} else if (this.type === 'tendril') {
forceScale = 2.5;
} else {
forceScale = 1.5;
}
this.vx += cx * forceScale * dt * 60;
this.vy += cy * forceScale * dt * 60;
// 阻力
this.vx *= 0.96;
this.vy *= 0.96;
// 位置更新
this.x += this.vx * dt * 60;
this.y += this.vy * dt * 60;
// 距离限制(杯壁)
const dx = this.x - CX;
const dy = this.y - CY;
const dist = Math.sqrt(dx*dx + dy*dy);
const maxDist = RADIUS - 10;
if (dist > maxDist) {
const nx = dx / dist;
const ny = dy / dist;
this.x = CX + nx * maxDist;
this.y = CY + ny * maxDist;
// 反弹
const dot = this.vx * nx + this.vy * ny;
this.vx -= 1.5 * dot * nx;
this.vy -= 1.5 * dot * ny;
}
// 生命衰减
this.life -= dt / this.maxLife;
if (this.life <= 0) {
this.active = false;
}
// 大小变化(膨胀)
if (this.type === 'mist') {
this.size += dt * 4;
} else {
this.size += dt * 0.8;
}
}
draw(ctx) {
if (!this.active) return;
const alpha = Math.max(0, this.life) * this.density;
const size = this.size;
// 不同类型不同颜色
let r, g, b;
if (this.type === 'core') {
r = 5; g = 8; b = 15;
} else if (this.type === 'tendril') {
r = 8; g = 12; b = 20;
} else {
r = 15; g = 20; b = 30;
}
const gradient = ctx.createRadialGradient(this.x, this.y, 0, this.x, this.y, size);
gradient.addColorStop(0, `rgba(${r},${g},${b},${alpha})`);
gradient.addColorStop(0.5, `rgba(${r},${g},${b},${alpha * 0.5})`);
gradient.addColorStop(1, `rgba(${r},${g},${b},0)`);
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(this.x, this.y, size, 0, Math.PI * 2);
ctx.fill();
}
}
// 初始化粒子池
for (let i = 0; i < PARTICLE_MAX; i++) {
particles.push(new Particle());
}
function getDeadParticle() {
for (let i = 0; i < particles.length; i++) {
if (!particles[i].active) return particles[i];
}
return null;
}
function spawnInk(x, y) {
// 核心 - 紧密的中心
for (let i = 0; i < 80; i++) {
const p = getDeadParticle();
if (!p) break;
p.spawn(x, y, {
speed: Math.random() * 0.5,
maxLife: 6 + Math.random() * 3,
density: 0.9 + Math.random() * 0.1,
size: 8 + Math.random() * 6,
type: 'core'
});
}
// 爆发粒子
for (let i = 0; i < 200; i++) {
const p = getDeadParticle();
if (!p) break;
p.spawn(x, y, {
speed: Math.random() * 4 + 2,
maxLife: 4 + Math.random() * 3,
density: 0.5 + Math.random() * 0.4,
size: 3 + Math.random() * 3,
type: 'normal'
});
}
// 卷须粒子(沿特定方向)
for (let i = 0; i < 80; i++) {
const p = getDeadParticle();
if (!p) break;
const angle = Math.random() * Math.PI * 2;
p.spawn(x, y, {
angle: angle,
speed: Math.random() * 3 + 3,
maxLife: 5 + Math.random() * 3,
density: 0.6 + Math.random() * 0.3,
size: 1.5 + Math.random() * 1.5,
type: 'tendril'
});
}
}
// 鼠标点击注入
let lastDrop = -1000;
canvas.addEventListener('click', (e) => {
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left) * (W / rect.width);
const y = (e.clientY - rect.top) * (H / rect.height);
spawnInk(x, y);
});
// 动画循环
let lastTime = 0;
let autoTimer = 0;
function animate(time) {
const dt = Math.min((time - lastTime) / 1000, 1/30);
lastTime = time;
// 自动滴入
autoTimer += dt;
if (autoTimer > 4) {
autoTimer = 0;
const offset = (Math.random() - 0.5) * 40;
spawnInk(CX + offset, CY + offset);
}
// 清空墨水画布(带衰减效果,保留淡淡的痕迹)
inkCtx.globalCompositeOperation = 'destination-out';
inkCtx.fillStyle = 'rgba(0,0,0,0.04)';
inkCtx.fillRect(0, 0, W, H);
inkCtx.globalCompositeOperation = 'source-over';
// 更新和绘制粒子
for (let p of particles) {
if (p.active) {
p.update(dt, time);
p.draw(inkCtx);
}
}
// 主画布渲染
ctx.clearRect(0, 0, W, H);
// 水的底色(深青色,模拟清水中带蓝调)
const waterGrad = ctx.createRadialGradient(CX, CY - 50, 50, CX, CY, RADIUS);
waterGrad.addColorStop(0, '#1a2840');
waterGrad.addColorStop(0.5, '#0e1a2e');
waterGrad.addColorStop(1, '#06101c');
ctx.fillStyle = waterGrad;
ctx.beginPath();
ctx.arc(CX, CY, RADIUS, 0, Math.PI * 2);
ctx.fill();
// 绘制墨水层
ctx.drawImage(inkCanvas, 0, 0);
// 顶部光照效果
const lightGrad = ctx.createRadialGradient(CX, CY - 200, 0, CX, CY - 200, 400);
lightGrad.addColorStop(0, 'rgba(150, 200, 255, 0.15)');
lightGrad.addColorStop(0.5, 'rgba(100, 150, 220, 0.05)');
lightGrad.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = lightGrad;
ctx.beginPath();
ctx.arc(CX, CY, RADIUS, 0, Math.PI * 2);
ctx.fill();
// 杯子边缘高光
ctx.strokeStyle = 'rgba(180, 220, 255, 0.3)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(CX, CY, RADIUS - 1, 0, Math.PI * 2);
ctx.stroke();
// 内侧暗角
ctx.strokeStyle = 'rgba(0, 0, 0, 0.4)';
ctx.lineWidth = 8;
ctx.beginPath();
ctx.arc(CX, CY, RADIUS - 6, 0, Math.PI * 2);
ctx.stroke();
requestAnimationFrame(animate);
}
// 初始滴入
setTimeout(() => spawnInk(CX, CY), 500);
requestAnimationFrame(animate);
```
我觉得这个方案不错,但是还可以优化:
1. **去除自动滴入太频繁** - 改为初次滴入后偶尔再来一次
2. **更明显的卷须** - 让卷须粒子更细更亮
3. **更好的视觉** - 增加杯子的细节
让我再细化:
**关于"分形卷须不断弯曲、分叉":**
要真正实现分形效果,可以使用:
1. 多尺度噪声叠加(不同频率的curl noise)
2. 粒子在不同生命周期使用不同尺度
让我修改curl函数:
```javascript
function curlMultiScale(x, y, t) {
let dx = 0, dy = 0;
// 大尺度(宏观流向)
dx += perlin2(x * 0.5, y * 0.5 + 0.3 + t) * 0.5;
dy += perlin2(x * 0.5 + 0.3, y * 0.5 + t) * 0.5;
// 中尺度(卷须)
dx += perlin2(x + 10, y + 10 + t * 0.5) * 0.3;
dy += perlin2(x + 10, y + 10 + t * 0.5) * 0.3;
// 小尺度(细节)
dx += perlin2(x * 3, y * 3 + 100 + t * 0.3) * 0.2;
dy += perlin2(x * 3 + 100, y * 3 + t * 0.3) * 0.2;
// 旋转让它变成无散度
return [dy, -dx];
}
```
实际上,curl noise本质就是无散度的,让我用标准的curl:
```javascript
function curl(x, y, t) {
const e = 0.3;
// 用两个独立的噪声场计算梯度
const n1 = perlin2(x, y + e + t);
const n2 = perlin2(x, y - e + t);
const n3 = perlin2(x + e, y + t);
const n4 = perlin2(x - e, y + t);
// curl = (dN/dy, -dN/dx)
return [(n1 - n2) / (2 * e), -(n3 - n4) / (2 * e)];
}
```
这就是标准的curl noise,本身就有湍流和分叉特性。
**让我重写最终代码:**
我会做以下改进:
1. 使用更好的颜色(清水带淡淡蓝调)
2. 增加杯子的视觉细节
3. 更好的粒子分布
4. 优化渲染
实际上让我再考虑一下:用户希望的是 **俯视** 还是 **侧视**?
"墨水滴入清水"通常是侧视角度,能看到墨水从上方落下的过程。但俯视更能展示扩散的复杂模式。
考虑到用户强调"绽放"、"羽状"、"分形卷须",这些更适合俯视展示。所以我会用俯视。
但是!俯视下,墨水从空中滴入后还要"沉入水中",这有点矛盾。所以我可能会做:
- 半俯视角(约70度)
- 或者纯俯视,但墨水从中心爆发(已经"在水里"了)
我选择**纯俯视,墨水从中心爆发**——这样最直接展示扩散效果。
**杯子视觉:**
我会画一个圆形的杯子(从上往下看):
- 外圈:杯壁(深色边缘)
- 中间:水面(深蓝灰色)
- 顶部光照:从上方斜射的光,在水面形成亮区
- 杯子边缘的高光
让我再考虑画面构图,使用全屏:
```css
body {
background: radial-gradient(circle at center, #0a1525 0%, #050810 70%);
}
#canvas {
border-radius: 50%;
box-shadow: 0 0 100px rgba(40,80,140,0.2), 0 0 0 1px rgba(100,150,200,0.1);
}
```
让我最终确定方案并写出完整代码。
我想让画面更有戏剧性,所以:
- 全屏深色背景,营造氛围
- 中间一个圆形的"水杯"区域
- 墨水在杯中扩散
- 顶部柔光
- 文字标注
**最终代码:**
让我把所有东西整合起来:
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Ink Diffusion · 墨水扩散</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
overflow: hidden;
background: #03060c;
font-family: -apple-system, 'Helvetica Neue', sans-serif;
color: rgba(255,255,255,0.6);
}
body {
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(ellipse at 50% 30%, rgba(40,80,140,0.15) 0%, transparent 60%),
radial-gradient(ellipse at center, #0a1525 0%, #03060c 80%);
}
#stage {
position: relative;
}
#canvas {
display: block;
border-radius: 50%;
cursor: crosshair;
box-shadow:
inset 0 0 0 1px rgba(120,180,240,0.08),
0 0 0 1px rgba(0,0,0,0.6),
0 30px 80px rgba(0,0,0,0.7),
0 0 120px rgba(40,80,140,0.15);
}
.label {
position: absolute;
pointer-events: none;
font-weight: 200;
letter-spacing: 0.3em;
text-transform: uppercase;
font-size: 11px;
}
.label.top-left { top: -30px; left: 0; }
.label.bottom-right { bottom: -30px; right: 0; opacity: 0.4; }
.hint {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
font-size: 11px;
letter-spacing: 0.3em;
text-transform: uppercase;
opacity: 0.4;
font-weight: 200;
}
</style>
</head>
<body>
<div id="stage">
<div class="label top-left">INK · WATER · FLOW</div>
<canvas id="canvas"></canvas>
<div class="label bottom-right">curl noise advection</div>
</div>
<div class="hint">Click anywhere to drop ink</div>
<script>
// ... 完整JS代码
</script>
</body>
</html>
```
现在写JS代码:
```javascript
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d', { alpha: true });
// 离屏画布
const inkLayer = document.createElement('canvas');
const inkCtx = inkLayer.getContext('2d');
let W, H, CX, CY, RADIUS, DPR;
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
const size = Math.min(window.innerWidth, window.innerHeight) - 100;
canvas.width = size * DPR;
canvas.height = size * DPR;
canvas.style.width = size + 'px';
canvas.style.height = size + 'px';
inkLayer.width = size * DPR;
inkLayer.height = size * DPR;
ctx.scale(DPR, DPR);
inkCtx.scale(DPR, DPR);
W = size;
H = size;
CX = W / 2;
CY = H / 2;
RADIUS = W / 2 - 2;
}
resize();
window.addEventListener('resize', () => {
resize();
initBackground();
});
// Perlin Noise
const perm = new Uint8Array(512);
(() => {
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
for (let i = 255; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[p[i], p[j]] = [p[j], p[i]];
}
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
})();
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + (b - a) * t; }
function grad2(hash, x, y) {
const h = hash & 7;
const u = h < 4 ? x : y;
const v = h < 4 ? y : x;
return ((h & 1) ? -u : u) + ((h & 2) ? -2 * v : 2 * v);
}
function noise2(x, y) {
const X = Math.floor(x) & 255;
const Y = Math.floor(y) & 255;
x -= Math.floor(x);
y -= Math.floor(y);
const u = fade(x);
const v = fade(y);
const A = perm[X] + Y;
const B = perm[X + 1] + Y;
return lerp(
lerp(grad2(perm[A], x, y), grad2(perm[B], x - 1, y), u),
lerp(grad2(perm[A + 1], x, y - 1), grad2(perm[B + 1], x - 1, y - 1), u),
v
);
}
function curl(x, y, t) {
const e = 0.4;
return [
(noise2(x, y + e + t) - noise2(x, y - e + t)) / (2 * e),
-(noise2(x + e, y + t) - noise2(x - e, y + t)) / (2 * e)
];
}
// 粒子
const PARTICLE_MAX = 3000;
const particles = [];
class Particle {
constructor() {
this.active = false;
}
spawn(x, y, opts) {
this.x = x + (Math.random() - 0.5) * 3;
this.y = y + (Math.random() - 0.5) * 3;
const angle = opts.angle !== undefined ? opts.angle : Math.random() * Math.PI * 2;
const speed = opts.speed !== undefined ? opts.speed : 0;
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * speed;
this.life = 1;
this.maxLife = opts.maxLife;
this.density = opts.density;
this.baseSize = opts.size;
this.size = opts.size;
this.type = opts.type;
this.curlScale = opts.curlScale || 0.008;
this.curlStrength = opts.curlStrength || 1.0;
this.active = true;
}
update(dt, time) {
if (!this.active) return;
// 多尺度curl noise
const s1 = this.curlScale;
const s2 = s1 * 2.5;
const t = time * 0.00008;
const c1 = curl(this.x * s1, this.y * s1, t);
const c2 = curl(this.x * s2 + 50, this.y * s2 + 50, t * 1.3);
const fx = (c1[0] + c2[0] * 0.4) * this.curlStrength;
const fy = (c1[1] + c2[0] * 0.4) * this.curlStrength; // 这里有错,应该是c2[1]
// 修正
// ...
}
}
```
让我重新整理一下,写一个清晰的最终版本:
```javascript
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const inkLayer = document.createElement('canvas');
const inkCtx = inkLayer.getContext('2d');
let W, H, CX, CY, RADIUS, DPR;
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
const size = Math.min(window.innerWidth - 60, window.innerHeight - 100, 800);
for (const c of [canvas, inkLayer]) {
c.width = size * DPR;
c.height = size * DPR;
}
canvas.style.width = size + 'px';
canvas.style.height = size + 'px';
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
inkCtx.setTransform(DPR, 0, 0, DPR, 0, 0);
W = size;
H = size;
CX = W / 2;
CY = H / 2;
RADIUS = W / 2;
}
// Perlin noise setup
const perm = new Uint8Array(512);
(() => {
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
for (let i = 255; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[p[i], p[j]] = [p[j], p[i]];
}
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
})();
const fade = t => t * t * t * (t * (t * 6 - 15) + 10);
const lerp = (a, b, t) => a + (b - a) * t;
const grad2 = (h, x, y) => {
const g = h & 7;
const u = g < 4 ? x : y;
const v = g < 4 ? y : x;
return ((g & 1) ? -u : u) + ((g & 2) ? -2 * v : 2 * v);
};
function noise2(x, y) {
const X = Math.floor(x) & 255;
const Y = Math.floor(y) & 255;
x -= Math.floor(x);
y -= Math.floor(y);
const u = fade(x);
const v = fade(y);
const A = perm[X] + Y;
const B = perm[X + 1] + Y;
return lerp(
lerp(grad2(perm[A], x, y), grad2(perm[B], x - 1, y), u),
lerp(grad2(perm[A + 1], x, y - 1), grad2(perm[B + 1], x - 1, y - 1), u),
v
);
}
function curl2(x, y, t) {
const e = 0.5;
return [
(noise2(x, y + e + t) - noise2(x, y - e + t)) / (2 * e),
-(noise2(x + e, y + t) - noise2(x - e, y + t)) / (2 * e)
];
}
// Particles
const particles = [];
const POOL_SIZE = 3500;
let nextSpawn = 0;
class Particle {
constructor() {
this.active = false;
}
spawn(x, y, o) {
this.x = x;
this.y = y;
const a = o.angle ?? Math.random() * Math.PI * 2;
const s = o.speed ?? 0;
this.vx = Math.cos(a) * s;
this.vy = Math.sin(a) * s;
this.life = 1;
this.maxLife = o.maxLife;
this.density0 = o.density;
this.size = o.size;
this.growth = o.growth ?? 0.3;
this.curlScale = o.curlScale ?? 0.008;
this.curlStrength = o.curlStrength ?? 1.5;
this.damping = o.damping ?? 0.97;
this.active = true;
}
update(dt, time) {
if (!this.active) return;
const t = time * 0.0001;
const c = curl2(this.x * this.curlScale, this.y * this.curlScale, t);
this.vx += c[0] * this.curlStrength * dt * 60;
this.vy += c[1] * this.curlStrength * dt * 60;
this.vx *= this.damping;
this.vy *= this.damping;
this.x += this.vx * dt * 60;
this.y += this.vy * dt * 60;
// 边界
const dx = this.x - CX;
const dy = this.y - CY;
const d2 = dx * dx + dy * dy;
const maxR = RADIUS - 8;
if (d2 > maxR * maxR) {
const d = Math.sqrt(d2);
const nx = dx / d;
const ny = dy / d;
this.x = CX + nx * maxR;
this.y = CY + ny * maxR;
const dot = this.vx * nx + this.vy * ny;
this.vx -= 1.5 * dot * nx;
this.vy -= 1.5 * dot * ny;
this.vx *= 0.5;
this.vy *= 0.5;
}
this.size += this.growth * dt;
this.life -= dt / this.maxLife;
if (this.life <= 0) this.active = false;
}
draw(c) {
if (!this.active) return;
const life = this.life;
const a = life * this.density0;
const s = this.size;
// 黑色墨水,根据密度调整
const grad = c.createRadialGradient(this.x, this.y, 0, this.x, this.y, s);
grad.addColorStop(0, `rgba(2,4,8,${a * 0.9})`);
grad.addColorStop(0.4, `rgba(3,6,12,${a * 0.5})`);
grad.addColorStop(1, 'rgba(4,8,16,0)');
c.fillStyle = grad;
c.beginPath();
c.arc(this.x, this.y, s, 0, Math.PI * 2);
c.fill();
}
}
for (let i = 0; i < POOL_SIZE; i++) particles.push(new Particle());
function getParticle() {
// 循环索引找空闲粒子
for (let i = 0; i < POOL_SIZE; i++) {
nextSpawn = (nextSpawn + 1) % POOL_SIZE;
if (!particles[nextSpawn].active) return particles[nextSpawn];
}
return null;
}
function dropInk(x, y) {
// 1. 内核(高密度,慢速)
for (let i = 0; i < 60; i++) {
const p = getParticle();
if (!p) break;
p.spawn(x, y, {
speed: Math.random() * 0.3,
maxLife: 5 + Math.random() * 3,
density: 0.95,
size: 7 + Math.random() * 5,
growth: 0.15,
curlScale: 0.006,
curlStrength: 0.8,
damping: 0.94
});
}
// 2. 中层爆发(中等密度,向外扩散)
for (let i = 0; i < 200; i++) {
const p = getParticle();
if (!p) break;
p.spawn(x, y, {
speed: Math.random() * 3 + 1.5,
maxLife: 4 + Math.random() * 3,
density: 0.55 + Math.random() * 0.25,
size: 2.5 + Math.random() * 2.5,
growth: 0.4,
curlScale: 0.008,
curlStrength: 1.5,
damping: 0.96
});
}
// 3. 卷须粒子(细长,沿curl noise方向)
for (let i = 0; i < 120; i++) {
const p = getParticle();
if (!p) break;
p.spawn(x, y, {
angle: Math.random() * Math.PI * 2,
speed: Math.random() * 4 + 3,
maxLife: 4 + Math.random() * 3,
density: 0.6 + Math.random() * 0.3,
size: 1 + Math.random() * 1.5,
growth: 0.6,
curlScale: 0.012,
curlStrength: 2.5,
damping: 0.975
});
}
// 4. 雾化粒子(最后阶段形成淡淡云雾)
for (let i = 0; i < 100; i++) {
const p = getParticle();
if (!p) break;
p.spawn(x, y, {
speed: Math.random() * 5 + 2,
maxLife: 8 + Math.random() * 4,
density: 0.25 + Math.random() * 0.2,
size: 2 + Math.random() * 3,
growth: 1.0,
curlScale: 0.005,
curlStrength: 1.2,
damping: 0.985
});
}
}
// 背景预渲染
let bgCanvas;
function initBackground() {
bgCanvas = document.createElement('canvas');
bgCanvas.width = W * DPR;
bgCanvas.height = H * DPR;
const bgCtx = bgCanvas.getContext('2d');
bgCtx.setTransform(DPR, 0, 0, DPR, 0, 0);
// 圆形剪裁
bgCtx.save();
bgCtx.beginPath();
bgCtx.arc(CX, CY, RADIUS, 0, Math.PI * 2);
bgCtx.clip();
// 水的底色(深青蓝)
const baseGrad = bgCtx.createRadialGradient(CX, CY - 80, 50, CX, CY + 100, RADIUS * 1.3);
baseGrad.addColorStop(0, '#1a2a44');
baseGrad.addColorStop(0.4, '#0d1830');
baseGrad.addColorStop(0.8, '#060e1c');
baseGrad.addColorStop(1, '#030610');
bgCtx.fillStyle = baseGrad;
bgCtx.fillRect(0, 0, W, H);
// 顶部柔光
const lightGrad = bgCtx.createRadialGradient(CX, CY - RADIUS * 0.5, 0, CX, CY - RADIUS * 0.5, RADIUS * 1.2);
lightGrad.addColorStop(0, 'rgba(150, 200, 255, 0.25)');
lightGrad.addColorStop(0.4, 'rgba(80, 140, 220, 0.1)');
lightGrad.addColorStop(1, 'rgba(0, 0, 0, 0)');
bgCtx.fillStyle = lightGrad;
bgCtx.fillRect(0, 0, W, H);
// 水面波纹细节
bgCtx.globalAlpha = 0.05;
for (let i = 0; i < 30; i++) {
const rx = Math.random() * W;
const ry = Math.random() * H;
const r = Math.random() * 100 + 30;
const g = bgCtx.createRadialGradient(rx, ry, 0, rx, ry, r);
g.addColorStop(0, 'rgba(100, 150, 200, 1)');
g.addColorStop(1, 'rgba(100, 150, 200, 0)');
bgCtx.fillStyle = g;
bgCtx.beginPath();
bgCtx.arc(rx, ry, r, 0, Math.PI * 2);
bgCtx.fill();
}
bgCtx.globalAlpha = 1;
bgCtx.restore();
}
resize();
initBackground();
// 鼠标交互
canvas.addEventListener('click', (e) => {
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left);
const y = (e.clientY - rect.top);
// 限制在圆内
const dx = x - CX;
const dy = y - CY;
if (dx*dx + dy*dy < (RADIUS - 20) ** 2) {
dropInk(x, y);
}
});
// 动画
let lastT = performance.now();
let autoTimer = 0;
let firstDrop = false;
function frame(t) {
const dt = Math.min((t - lastT) / 1000, 1/30);
lastT = t;
// 自动首次滴入
if (!firstDrop) {
dropInk(CX, CY);
firstDrop = true;
}
autoTimer += dt;
if (autoTimer > 6) {
autoTimer = 0;
const ang = Math.random() * Math.PI * 2;
const r = Math.random() * RADIUS * 0.3;
dropInk(CX + Math.cos(ang) * r, CY + Math.sin(ang) * r);
}
// 清空墨水画布(淡化保留痕迹)
inkCtx.save();
inkCtx.setTransform(1, 0, 0, 1, 0, 0);
inkCtx.globalCompositeOperation = 'destination-out';
inkCtx.fillStyle = 'rgba(0,0,0,0.025)';
inkCtx.fillRect(0, 0, W * DPR, H * DPR);
inkCtx.globalCompositeOperation = 'source-over';
inkCtx.restore();
// 更新粒子
for (let p of particles) {
if (p.active) {
p.update(dt, t);
p.draw(inkCtx);
}
}
// 主画布合成
ctx.clearRect(0, 0, W, H);
// 绘制背景
ctx.drawImage(bgCanvas, 0, 0, W, H);
// 剪裁墨水层
ctx.save();
ctx.beginPath();
ctx.arc(CX, CY, RADIUS - 2, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(inkLayer, 0, 0, W, H);
ctx.restore();
// 杯子边缘高光
ctx.strokeStyle = 'rgba(180, 220, 255, 0.15)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(CX, CY, RADIUS - 1, 0, Math.PI * 2);
ctx.stroke();
// 内圈暗角
const vignette = ctx.createRadialGradient(CX, CY, RADIUS * 0.7, CX, CY, RADIUS);
vignette.addColorStop(0, 'rgba(0,0,0,0)');
vignette.addColorStop(1, 'rgba(0,0,0,0.5)');
ctx.fillStyle = vignette;
ctx.beginPath();
ctx.arc(CX, CY, RADIUS, 0, Math.PI * 2);
ctx.fill();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
```
等等,我需要再检查一下:
1. `setTransform` 后的画布坐标系问题
2. `drawImage` 的尺寸
3. 性能问题
让我简化:不要DPR scaling for inkLayer,让所有内容都在CSS像素空间,然后只在最后通过canvas属性放大。
实际上更简单:让所有canvas都用同样的DPR设置,然后用CSS缩放显示。
让我重新整理:
```javascript
// 简化版:直接使用CSS像素
function resize() {
const size = Math.min(window.innerWidth - 60, window.innerHeight - 100, 800);
const dpr = Math.min(window.devicePixelRatio || 1, 2);
for (const c of [canvas, inkLayer, bgCanvas]) {
c.width = size * dpr;
c.height = size * dpr;
}
canvas.style.width = size + 'px';
canvas.style.height = size + 'px';
for (const cx of [ctx, inkCtx]) {
cx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
W = size;
H = size;
CX = W / 2;
CY = H / 2;
RADIUS = W / 2 - 2;
}
```
这样所有的绘制坐标都是CSS像素,DPR放大在底层处理。
让我做最后整合:
考虑性能:
- 3000个粒子,每帧4次noise调用 = 12000次/帧
- 这在60fps下是720k次/秒,应该OK
- 但可以优化:预计算一些noise查询
实际上我觉得我可以减少粒子数到 2000-2500,效果应该还是很好。
另一个性能考虑:每次粒子draw都创建新的radialGradient,这是开销大的操作。可以缓存:
```javascript
// 预渲染粒子纹理
const particleTextures = {};
function getParticleTexture(size, alpha) {
const key = `${size}_${alpha.toFixed(2)}`;
if (particleTextures[key]) return particleTextures[key];
const c = document.createElement('canvas');
const s = size * 2;
c.width = c.height = s;
const cx = c.getContext('2d');
const grad = cx.createRadialGradient(s/2, s/2, 0, s/2, s/2, size);
grad.addColorStop(0, `rgba(2,4,8,${alpha * 0.9})`);
grad.addColorStop(0.4, `rgba(3,6,12,${alpha * 0.5})`);
grad.addColorStop(1, 'rgba(4,8,16,0)');
cx.fillStyle = grad;
cx.fillRect(0, 0, s, s);
particleTextures[key] = c;
return c;
}
```
不过这样会有缓存爆炸问题。简单点,限制几种纹理:
```javascript
const PARTICLE_TEXTURE_SIZES = [2, 4, 6, 8, 12, 16, 24, 32];
const textures = PARTICLE_TEXTURE_SIZES.map(size => {
const c = document.createElement('canvas');
const s = size * 2;
c.width = c.height = s;
const cx = c.getContext('2d');
const grad = cx.createRadialGradient(s/2, s/2, 0, s/2, s/2, size);
grad.addColorStop(0, 'rgba(2,4,8,1)');
grad.addColorStop(0.4, 'rgba(3,6,12,0.6)');
grad.addColorStop(1, 'rgba(4,8,16,0)');
cx.fillStyle = grad;
cx.fillRect(0, 0, s, s);
return c;
});
function getTexture(size) {
// 找最接近的纹理
let best = textures[0];
let bestDiff = Infinity;
for (let i = 0; i < PARTICLE_TEXTURE_SIZES.length; i++) {
const d = Math.abs(PARTICLE_TEXTURE_SIZES[i] - size);
if (d < bestDiff) {
bestDiff = d;
best = textures[i];
}
}
return best;
}
```
然后绘制时:
```javascript
draw(c) {
if (!this.active) return;
const life = this.life;
const alpha = life * this.density0;
const size = this.size;
const tex = getTexture(size);
c.globalAlpha = alpha;
c.drawImage(tex, this.x - size, this.y - size, size * 2, size * 2);
c.globalAlpha = 1;
}
```
这样性能会好很多。
让我整合所有优化,写出最终代码:
实际上为了简化,我可以不做纹理缓存优化,因为现代浏览器对radialGradient有优化。让我先简单实现,如果性能不够再优化。
最终决定方案:
1. Canvas全屏(圆形)
2. Perlin Noise + Curl Noise 驱动
3. 粒子系统(2000-3000个)
4. 不同类型的粒子(核心、中层、卷须、雾化)
5. 离屏画布累积墨水痕迹
6. 顶部光照
7. 鼠标点击注入
让我写最终的完整代码:
实际上,让我再优化一下粒子的渲染。直接画radialGradient很慢,使用纹理更高效:
```javascript
// 预先生成一些不同大小的粒子纹理
const texPool = [];
function makeParticleTexture(size) {
const s = Math.ceil(size * 2);
const c = document.createElement('canvas');
c.width = c.height = s;
const cx = c.getContext('2d');
const grad = cx.createRadialGradient(s/2, s/2, 0, s/2, s/2, size);
grad.addColorStop(0, 'rgba(0,0,0,1)');
grad.addColorStop(0.5, 'rgba(0,0,0,0.5)');
grad.addColorStop(1, 'rgba(0,0,0,0)');
cx.fillStyle = grad;
cx.fillRect(0, 0, s, s);
return c;
}
// 预生成几种大小
const TEX_SIZES = [3, 5, 8, 12, 18, 26, 38];
const TEX_POOL = TEX_SIZES.map(makeParticleTexture);
function getTex(size) {
for (let i = TEX_SIZES.length - 1; i >= 0; i--) {
if (size >= TEX_SIZES[i] * 0.6) return TEX_POOL[i];
}
return TEX_POOL[0];
}
```
然后绘制:
```javascript
draw(c) {
const a = this.life * this.density0;
if (a <= 0.01) return;
const size = this.size;
const tex = getTex(size);
const ts = tex.width;
c.globalAlpha = Math.min(1, a);
c.drawImage(tex, this.x - ts/2, this.y - ts/2);
}
```
这样性能会好得多。
不过等等,drawImage绘制纹理时,如果想要可变大小,需要指定宽高:
```javascript
c.drawImage(tex, this.x - size, this.y - size, size * 2, size * 2);
```
这比每次创建gradient快很多。
好,我现在写最终代码:
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Ink in Water · 墨水扩散</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%; overflow: hidden;
background: #03060c;
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif;
color: rgba(200, 220, 255, 0.5);
}
body {
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(ellipse 80% 60% at 50% 30%, rgba(60, 100, 160, 0.18) 0%, transparent 70%),
radial-gradient(ellipse at center, #0c1828 0%, #03060c 75%);
}
#stage {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
#canvas {
display: block;
border-radius: 50%;
cursor: crosshair;
box-shadow:
inset 0 0 0 1px rgba(140, 190, 240, 0.06),
0 0 0 1px rgba(0, 0, 0, 0.5),
0 40px 100px rgba(0, 0, 0, 0.6),
0 0 120px rgba(40, 80, 140, 0.12);
}
.meta {
position: absolute;
pointer-events: none;
font-weight: 200;
letter-spacing: 0.4em;
text-transform: uppercase;
font-size: 10px;
color: rgba(180, 210, 240, 0.35);
}
.meta.tl { top: -28px; left: 4px; }
.meta.tr { top: -28px; right: 4px; }
.meta.bl { bottom: -28px; left: 4px; }
.meta.br { bottom: -28px; right: 4px; opacity: 0.5; }
.hint {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
letter-spacing: 0.4em;
text-transform: uppercase;
opacity: 0.4;
font-weight: 200;
color: rgba(200, 220, 255, 0.7);
}
</style>
</head>
<body>
<div id="stage">
<div class="meta tl">Ink · Water · Flow</div>
<div class="meta tr">2024</div>
<canvas id="canvas"></canvas>
<div class="meta bl">Curl Noise Advection</div>
<div class="meta br">60fps · No Library</div>
</div>
<div class="hint">Click in the glass to release more ink</div>
<script>
// === Canvas 设置 ===
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const inkLayer = document.createElement('canvas');
const inkCtx = inkLayer.getContext('2d');
const bgLayer = document.createElement('canvas');
const bgCtx = bgLayer.getContext('2d');
let W, H, CX, CY, RADIUS, DPR;
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
const size = Math.min(window.innerWidth - 60, window.innerHeight - 100, 720);
for (const c of [canvas, inkLayer, bgLayer]) {
c.width = Math.floor(size * DPR);
c.height = Math.floor(size * DPR);
}
canvas.style.width = size + 'px';
canvas.style.height = size + 'px';
for (const cx of [ctx, inkCtx, bgCtx]) {
cx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
W = size; H = size;
CX = W / 2; CY = H / 2;
RADIUS = W / 2;
initBackground();
}
// === Perlin Noise ===
const perm = new Uint8Array(512);
(() => {
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
for (let i = 255; i > 0; i--) {
const j = (Math.random() * (i + 1)) | 0;
[p[i], p[j]] = [p[j], p[i]];
}
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
})();
const fade = t => t * t * t * (t * (t * 6 - 15) + 10);
const lerp = (a, b, t) => a + (b - a) * t;
function grad2(h, x, y) {
const g = h & 7;
const u = g < 4 ? x : y;
const v = g < 4 ? y : x;
return ((g & 1) ? -u : u) + ((g & 2) ? -2 * v : 2 * v);
}
function noise2(x, y) {
const X = Math.floor(x) & 255;
const Y = Math.floor(y) & 255;
x -= Math.floor(x);
y -= Math.floor(y);
const u = fade(x);
const v = fade(y);
const A = perm[X] + Y;
const B = perm[X + 1] + Y;
return lerp(
lerp(grad2(perm[A], x, y), grad2(perm[B], x - 1, y), u),
lerp(grad2(perm[A + 1], x, y - 1), grad2(perm[B + 1], x - 1, y - 1), u),
v
);
}
function curl2(x, y, t) {
const e = 0.5;
return [
(noise2(x, y + e + t) - noise2(x, y - e + t)) / (2 * e),
-(noise2(x + e, y + t) - noise2(x - e, y + t)) / (2 * e)
];
}
// === 粒子纹理池 ===
const TEX_SIZES = [3, 5, 8, 12, 18, 26, 38, 54];
const TEX_POOL = TEX_SIZES.map(size => {
const s = Math.ceil(size * 2);
const c = document.createElement('canvas');
c.width = c.height = s;
const cx = c.getContext('2d');
const grad = cx.createRadialGradient(s/2, s/2, 0, s/2, s/2, size);
grad.addColorStop(0, 'rgba(2, 3, 6, 1)');
grad.addColorStop(0.35, 'rgba(3, 5, 10, 0.65)');
grad.addColorStop(0.7, 'rgba(4, 8, 16, 0.2)');
grad.addColorStop(1, 'rgba(4, 8, 16, 0)');
cx.fillStyle = grad;
cx.fillRect(0, 0, s, s);
return c;
});
function getTexIdx(size) {
for (let i = TEX_SIZES.length - 1; i >= 0; i--) {
if (size >= TEX_SIZES[i] * 0.65) return i;
}
return 0;
}
// === 粒子 ===
const POOL_SIZE = 3500;
const particles = new Array(POOL_SIZE);
let searchStart = 0;
class Particle {
constructor() { this.active = false; }
spawn(x, y, o) {
this.x = x; this.y = y;
const a = o.angle ?? Math.random() * Math.PI * 2;
const s = o.speed ?? 0;
this.vx = Math.cos(a) * s;
this.vy = Math.sin(a) * s;
this.life = 1;
this.maxLife = o.maxLife;
this.density0 = o.density;
this.size = o.size;
this.size0 = o.size;
this.growth = o.growth;
this.curlScale = o.curlScale;
this.curlStrength = o.curlStrength;
this.damping = o.damping;
this.texIdx = getTexIdx(this.size);
this.active = true;
}
update(dt, time) {
if (!this.active) return;
const t = time * 0.0001;
const cs = this.curlScale;
const c = curl2(this.x * cs, this.y * cs, t);
this.vx += c[0] * this.curlStrength * dt * 60;
this.vy += c[1] * this.curlStrength * dt * 60;
this.vx *= this.damping;
this.vy *= this.damping;
this.x += this.vx * dt * 60;
this.y += this.vy * dt * 60;
// 杯壁反弹
const dx = this.x - CX;
const dy = this.y - CY;
const d2 = dx * dx + dy * dy;
const maxR = RADIUS - 6;
if (d2 > maxR * maxR) {
const d = Math.sqrt(d2);
const nx = dx / d;
const ny = dy / d;
this.x = CX + nx * maxR;
this.y = CY + ny * maxR;
const dot = this.vx * nx + this.vy * ny;
this.vx -= 1.5 * dot * nx;
this.vy -= 1.5 * dot * ny;
this.vx *= 0.4;
this.vy *= 0.4;
}
// 大小变化
this.size += this.growth * dt;
// 生命衰减
this.life -= dt / this.maxLife;
if (this.life <= 0) {
this.active = false;
}
}
draw(c) {
if (!this.active) return;
const a = this.life * this.density0;
if (a < 0.01) return;
const tex = TEX_POOL[this.texIdx];
const ts = tex.width / DPR;
c.globalAlpha = Math.min(1, a);
c.drawImage(tex, this.x - ts/2, this.y - ts/2, ts, ts);
}
}
for (let i = 0; i < POOL_SIZE; i++) particles[i] = new Particle();
function getDeadParticle() {
for (let i = 0; i < POOL_SIZE; i++) {
searchStart = (searchStart + 1) % POOL_SIZE;
if (!particles[searchStart].active) return particles[searchStart];
}
return null;
}
function dropInk(x, y) {
// 内核:紧密的中心
for (let i = 0; i < 70; i++) {
const p = getDeadParticle();
if (!p) break;
p.spawn(x, y, {
speed: Math.random() * 0.3,
maxLife: 5 + Math.random() * 3,
density: 0.95,
size: 10 + Math.random() * 8,
growth: 0.15,
curlScale: 0.005,
curlStrength: 0.7,
damping: 0.93
});
}
// 中层爆发
for (let i = 0; i < 200; i++) {
const p = getDeadParticle();
if (!p) break;
p.spawn(x, y, {
speed: Math.random() * 3.5 + 1.5,
maxLife: 4 + Math.random() * 3,
density: 0.55 + Math.random() * 0.25,
size: 4 + Math.random() * 4,
growth: 0.45,
curlScale: 0.009,
curlStrength: 1.6,
damping: 0.96
});
}
// 卷须粒子
for (let i = 0; i < 130; i++) {
const p = getDeadParticle();
if (!p) break;
p.spawn(x, y, {
angle: Math.random() * Math.PI * 2,
speed: Math.random() * 4 + 3,
maxLife: 4 + Math.random() * 3,
density: 0.65 + Math.random() * 0.3,
size: 2 + Math.random() * 2,
growth: 0.7,
curlScale: 0.014,
curlStrength: 2.6,
damping: 0.975
});
}
// 雾化粒子
for (let i = 0; i < 100; i++) {
const p = getDeadParticle();
if (!p) break;
p.spawn(x, y, {
speed: Math.random() * 5 + 2,
maxLife: 8 + Math.random() * 5,
density: 0.22 + Math.random() * 0.18,
size: 3 + Math.random() * 4,
growth: 1.1,
curlScale: 0.006,
curlStrength: 1.2,
damping: 0.985
});
}
}
// === 背景预渲染 ===
function initBackground() {
bgCtx.clearRect(0, 0, W, H);
bgCtx.save();
bgCtx.beginPath();
bgCtx.arc(CX, CY, RADIUS - 1, 0, Math.PI * 2);
bgCtx.clip();
// 基础水色
const baseGrad = bgCtx.createRadialGradient(CX, CY - RADIUS * 0.3, RADIUS * 0.1, CX, CY + RADIUS * 0.3, RADIUS * 1.2);
baseGrad.addColorStop(0, '#1d2e4c');
baseGrad.addColorStop(0.35, '#10203a');
baseGrad.addColorStop(0.75, '#070f1e');
baseGrad.addColorStop(1, '#02060e');
bgCtx.fillStyle = baseGrad;
bgCtx.fillRect(0, 0, W, H);
// 顶部柔光
const lightGrad = bgCtx.createRadialGradient(CX, CY - RADIUS * 0.6, 0, CX, CY - RADIUS * 0.4, RADIUS * 1.4);
lightGrad.addColorStop(0, 'rgba(170, 215, 255, 0.32)');
lightGrad.addColorStop(0.35, 'rgba(90, 150, 220, 0.12)');
lightGrad.addColorStop(1, 'rgba(0, 0, 0, 0)');
bgCtx.fillStyle = lightGrad;
bgCtx.fillRect(0, 0, W, H);
// 水面微纹理
bgCtx.globalAlpha = 0.06;
for (let i = 0; i < 25; i++) {
const rx = Math.random() * W;
const ry = Math.random() * H;
const r = Math.random() * 80 + 30;
const g = bgCtx.createRadialGradient(rx, ry, 0, rx, ry, r);
g.addColorStop(0, 'rgba(120, 170, 220, 1)');
g.addColorStop(1, 'rgba(120, 170, 220, 0)');
bgCtx.fillStyle = g;
bgCtx.beginPath();
bgCtx.arc(rx, ry, r, 0, Math.PI * 2);
bgCtx.fill();
}
bgCtx.globalAlpha = 1;
bgCtx.restore();
}
// === 鼠标交互 ===
canvas.addEventListener('click', (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const dx = x - CX;
const dy = y - CY;
if (dx * dx + dy * dy < (RADIUS - 30) ** 2) {
dropInk(x, y);
}
});
// === 主循环 ===
let lastT = performance.now();
let autoTimer = 5; // 5秒后第一次自动滴入
let dropped = false;
function frame(t) {
const dt = Math.min((t - lastT) / 1000, 1/30);
lastT = t;
// 初始滴入
if (!dropped) {
dropInk(CX, CY);
dropped = true;
}
autoTimer += dt;
if (autoTimer > 8) {
autoTimer = 0;
const ang = Math.random() * Math.PI * 2;
const r = Math.random() * RADIUS * 0.4;
dropInk(CX + Math.cos(ang) * r, CY + Math.sin(ang) * r);
}
// 墨水层淡化
inkCtx.save();
inkCtx.setTransfor
⚠ Output was interrupted before a normal finish signal. The content below may be incomplete. Click Rerun to try again.
This run finished without a final content stream. The provider only returned reasoning/intermediate tokens, so the reasoning panel above is expanded instead of showing a blank result.
11.3s
TTFT
60
Reasoning TPS
289s · ≈17,392 tok
—
Output TPS
17,392
Total Tokens (estimated)
Elapsed 301s · Average 60 tok/s · Peak 315 tok/s · Cost ≈$0.077
智谱 GLM-5.2
glm-5.2
用户要求一个单文件 HTML,用原生 Canvas 实现墨水滴入清水扩散的动画。要求:
1. 真实流体运动(平流与扩散)
2. 分形卷须、羽状墨团
3. 深色浓密内核、半透明丝缕状边缘
4. 顶部柔和打光
5. 60fps
6. 不使用外部库
我需要思考实现方案:
**核心技术方案:**
1. **流体模拟**:使用 Navier-Stokes 方程的简化版本(Jos Stam 的稳定流体算法 Stable Fluids)
- 速度场 u, v
- 密度场 ink(墨水浓度)
- 平流
- 扩散
- 外力(重力、湍流注入)
2. **网格分辨率**:为了 60fps,网格不能太大。可以用 128x128 或 160x160 的低分辨率模拟,然后用 canvas 渲染。
3. **渲染**:将密度场映射到 canvas 上,加上深度感(顶部打光、边缘丝缕)
让我设计具体的实现:
**Stable Fluids 算法:**
```
1. 添加力(湍流、重力)
2. 速度平流
3. 速度扩散
4. 投影(保证不可压缩)
5. 密度平流
6. 密度扩散(衰减)
```
简化版本,可以做:
- 速度场 advect 自身
- 密度场被速度场 advect
- 投影保持 divergence-free
- 密度乘以衰减系数模拟扩散消散
**初始条件:**
- 在中心位置注入一团密度
- 同时注入一个湍流速度场(向四周辐射,带旋涡)
**分形卷须:**
- 通过添加扰动(噪声)到速度场
- 多个旋涡中心,使墨水形成羽状分支
- 使用 curl-noise 风格的湍流
**渲染:**
- 根据密度值绘制每个网格单元
- 深色内核 + 半透明边缘
- 添加细节:根据密度梯度产生丝缕
- 顶部光照:使用垂直方向的明暗变化
让我开始写代码:
```javascript
const N = 128; // 网格大小
const size = (N + 2) * (N + 2);
const u = new Float32Array(size);
const v = new Float32Array(size);
const u_prev = new Float32Array(size);
const v_prev = new Float32Array(size);
const density = new Float32Array(size);
const density_prev = new Float32Array(size);
function IX(i, j) { return i + (N + 2) * j; }
```
需要实现:
- add_source
- diffuse (使用 Gauss-Seidel 迭代)
- advect (半拉格朗日)
- project (投影)
为了性能,我可以简化:
- 不做严格的 diffusion(用衰减代替)
- 只做 project 一次
让我写一个性能优化的版本:
```javascript
// 简化的 stable fluids
function set_bnd(b, x) {
for (let i = 1; i <= N; i++) {
x[IX(0, i)] = b === 1 ? -x[IX(1, i)] : x[IX(1, i)];
x[IX(N+1, i)] = b === 1 ? -x[IX(N, i)] : x[IX(N, i)];
x[IX(i, 0)] = b === 2 ? -x[IX(i, 1)] : x[IX(i, 1)];
x[IX(i, N+1)] = b === 2 ? -x[IX(i, N)] : x[IX(i, N)];
}
x[IX(0, 0)] = 0.5 * (x[IX(1, 0)] + x[IX(0, 1)]);
x[IX(0, N+1)] = 0.5 * (x[IX(1, N+1)] + x[IX(0, N)]);
x[IX(N+1, 0)] = 0.5 * (x[IX(N, 0)] + x[IX(N+1, 1)]);
x[IX(N+1, N+1)] = 0.5 * (x[IX(N, N+1)] + x[IX(N+1, N)]);
}
function lin_solve(b, x, x0, a, c) {
const cR = 1.0 / c;
for (let k = 0; k < 10; k++) {
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
x[IX(i, j)] = (x0[IX(i, j)] + a * (
x[IX(i-1, j)] + x[IX(i+1, j)] +
x[IX(i, j-1)] + x[IX(i, j+1)]
)) * cR;
}
}
set_bnd(b, x);
}
}
function diffuse(b, x, x0, diff, dt) {
const a = dt * diff * N * N;
lin_solve(b, x, x0, a, 1 + 4 * a);
}
function advect(b, d, d0, u, v, dt) {
const dt0 = dt * N;
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
let x = i - dt0 * u[IX(i, j)];
let y = j - dt0 * v[IX(i, j)];
if (x < 0.5) x = 0.5;
if (x > N + 0.5) x = N + 0.5;
if (y < 0.5) y = 0.5;
if (y > N + 0.5) y = N + 0.5;
const i0 = Math.floor(x);
const i1 = i0 + 1;
const j0 = Math.floor(y);
const j1 = j0 + 1;
const s1 = x - i0;
const s0 = 1 - s1;
const t1 = y - j0;
const t0 = 1 - t1;
d[IX(i, j)] = s0 * (t0 * d0[IX(i0, j0)] + t1 * d0[IX(i0, j1)]) +
s1 * (t0 * d0[IX(i1, j0)] + t1 * d0[IX(i1, j1)]);
}
}
set_bnd(b, d);
}
function project(u, v, p, div) {
const h = 1.0 / N;
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
div[IX(i, j)] = -0.5 * h * (
u[IX(i+1, j)] - u[IX(i-1, j)] +
v[IX(i, j+1)] - v[IX(i, j-1)]
);
p[IX(i, j)] = 0;
}
}
set_bnd(0, div);
set_bnd(0, p);
lin_solve(0, p, div, 1, 4);
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
u[IX(i, j)] -= 0.5 * (p[IX(i+1, j)] - p[IX(i-1, j)]) / h;
v[IX(i, j)] -= 0.5 * (p[IX(i, j+1)] - p[IX(i, j-1)]) / h;
}
}
set_bnd(1, u);
set_bnd(2, v);
}
```
这个标准实现可能太慢了(128x128x10 迭代)。让我考虑用更小的网格 N=64 或者 80。
也可以用更聪明的方法:用纹理 + WebGL,但用户要求"原生 Canvas",那就用 2D canvas + ImageData。
为了 60fps,我应该:
1. N = 80 左右
2. lin_solve 迭代次数减少到 4-6 次
3. 用 ImageData 直接渲染
或者可以用更小的模拟网格 + 高分辨率的渲染(双线性插值)。
让我用 N = 100 试试。
**渲染策略:**
- 创建一个 N x N 的 ImageData
- 每个像素根据密度值和位置渲染颜色
- 顶部光照:颜色随 y 变化(顶部亮一点)
- 然后将这个小 canvas 放大到目标尺寸(imageSmoothingEnabled = true)
更好的方案:直接在显示分辨率 canvas 上渲染,但用较低分辨率的网格数据,对网格进行双线性插值。
实际上,最简单高效的方式:
- 在一个 N x N 的 offscreen canvas 上渲染密度
- 然后用 drawImage 把它放大到主 canvas
- 浏览器的图像缩放会自动进行平滑
但这样会失去细节。一个改进是:在渲染时加入高频噪声/丝缕纹理。
让我再想想渲染:
- 背景:水的颜色(带顶部高光的渐变,模拟一杯水)
- 墨水:黑色,alpha 由密度决定
- 添加深度感:根据密度的局部梯度(边缘),增加细节
可以这样:
1. 先渲染水的背景(顶部高光、底部稍暗)
2. 渲染墨水:根据 density[i,j] 设置黑色像素,alpha = density
3. 主 canvas 用 ctx.drawImage 放大小 canvas
为了让墨水看起来更"丝缕状",可以在渲染前对密度做边缘增强,或者用细节噪声扰动。
**初始爆炸效果:**
- 在中心点的高密度
- 多个辐射方向的初始速度(形成花瓣状)
- 添加一些旋涡(curl noise)
```javascript
function addInk(cx, cy, radius, amount) {
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
const dx = i - cx;
const dy = j - cy;
const d = Math.sqrt(dx*dx + dy*dy);
if (d < radius) {
const f = (1 - d/radius) * amount;
density[IX(i, j)] += f;
// 添加辐射速度 + 旋涡
const angle = Math.atan2(dy, dx);
const swirl = 0.5; // 旋涡强度
u[IX(i, j)] += Math.cos(angle) * f * 3 + Math.cos(angle + Math.PI/2) * f * swirl;
v[IX(i, j)] += Math.sin(angle) * f * 3 + Math.sin(angle + Math.PI/2) * f * swirl;
}
}
}
}
```
为了让效果更好看,可以加入持续的小扰动(curl noise)让墨水不断变形。
**自动重新触发:**
- 当墨水完全消散后,自动重新注入
- 或者用户点击屏幕触发
我想让效果自动循环 + 点击触发。
**完整代码结构:**
```html
<!DOCTYPE html>
<html>
<head>
<style>
body { margin: 0; background: #0a0a0a; overflow: hidden; }
canvas { display: block; }
.info { position: fixed; ... }
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
// 流体模拟代码
// 渲染代码
// 主循环
</script>
</body>
</html>
```
让我开始写完整代码:
实际上,让我用更好的方法。考虑性能,我使用 N = 96 的网格,并且优化迭代。
我考虑用 RGBA Uint8ClampedArray 直接操作 ImageData,但密度计算用 Float32Array。
让我重新组织代码:
```javascript
const N = 96;
const NN = N + 2;
const SIZE = NN * NN;
const u = new Float32Array(SIZE);
const v = new Float32Array(SIZE);
const u0 = new Float32Array(SIZE);
const v0 = new Float32Array(SIZE);
const d = new Float32Array(SIZE);
const d0 = new Float32Array(SIZE);
const p = new Float32Array(SIZE);
const div = new Float32Array(SIZE);
// 索引
function IX(i, j) { return i + NN * j; }
```
边界处理:杯子有壁,所以应该是反弹边界(slip wall)。但墨水可以消散。
实际上,为了让效果好看,我让边界是开放的(墨水可以"流出"边界但不会显示)。
让我再优化一下:使用一维数组已经够了,但 JS 引擎对 typed array 操作应该快。
让我写出完整版本:
```javascript
function simulate(dt) {
// 速度平流
[u0, u] = [u, u0]; // 交换
[v0, v] = [v, v0];
advect(1, u, u0, u0, v0, dt);
advect(2, v, v0, u0, v0, dt);
// 投影
project(u, v, p, div);
// 添加持续的小扰动
// 密度平流
[d0, d] = [d, d0];
advect(0, d, d0, u, v, dt);
// 密度衰减(扩散)
for (let i = 0; i < SIZE; i++) {
d[i] *= 0.995;
}
}
```
为了"分形的卷须不断弯曲、分叉",需要在模拟过程中持续注入小扰动。可以用 simplex/perlin noise 生成持续的湍流。
实际上,由于墨水本身的爆炸就带有旋涡分量,加上 Navier-Stokes 的非线性,会自然产生卷曲。
但是为了让卷曲更明显,可以:
1. 添加少量持续的 curl noise 力场
2. 或者在每个时间步添加细微扰动
我倾向于不添加持续扰动,让爆炸自身演化。如果不够,再添加。
**关于墨水滴入瞬间**:
- 应该有一个明显的"爆炸"瞬间
- 然后慢慢消散
模拟顺序:
1. 初始爆炸(中心注入高密度 + 辐射 + 旋涡)
2. 模拟平流、扩散
3. 衰减
4. 当密度总和低于阈值时,重新触发
让我写完整代码。
**性能优化:**
- 使用 typed array
- 内联 IX 函数(避免函数调用开销)
- 减少迭代次数(4-6 次)
让我先用 N = 80 试试,迭代 4 次。
渲染:
```javascript
function render() {
// 离屏小 canvas
const imgData = offCtx.createImageData(N, N);
const data = imgData.data;
for (let j = 0; j < N; j++) {
for (let i = 0; i < N; i++) {
const idx = (j * N + i) * 4;
const density = d[IX(i+1, j+1)];
const alpha = Math.min(1, density);
// 水的颜色 (背景)
const r = 240 - alpha * 240;
const g = 245 - alpha * 245;
const b = 250 - alpha * 250;
data[idx] = r;
data[idx+1] = g;
data[idx+2] = b;
data[idx+3] = 255;
}
}
offCtx.putImageData(imgData, 0, 0);
// 绘制到主 canvas
ctx.drawImage(offCanvas, 0, 0, N, N, 0, 0, W, H);
}
```
为了让效果更细腻,可以在小 canvas 上做更复杂的渲染:
- 加入丝缕噪声
- 边缘渐变
让我加入一些细节。
**水的背景:**
- 用渐变绘制水杯
- 顶部高光
- 然后叠加墨水
或者直接把整个画面做成墨水扩散效果,背景是清水。
最终方案:
1. 主 canvas 全屏(响应式)
2. 离屏 canvas N x N 用于流体模拟+渲染
3. 每帧:
- 模拟流体
- 渲染 N x N ImageData
- drawImage 到主 canvas,平滑放大
4. 在主 canvas 上叠加:高光、轻微的暗角
让我开始写完整代码。
让我考虑一些细节:
**杯子的视觉表现:**
- 标题说"一杯清水",但我想让画面更抽象、更有冲击力
- 不画杯子轮廓,让墨水占据整个画面
- 顶部强光(白色光晕,柔和)
- 底部稍暗
或者,可以画一个圆形/方形容器边缘,让用户感觉这是俯视一杯水。
我倾向于:俯视视角(看进杯子),不画杯壁,让墨水自由扩散。顶部柔和打光通过径向渐变实现。
**色彩:**
- 水:略带蓝灰的白色(#f0f4f8 - 偏冷的清水)
- 墨水:纯黑 #050505
- 顶部高光:白色,柔和
**注入新墨水的逻辑:**
- 初始触发一次
- 每隔几秒(如 8 秒)自动触发一次
- 点击/触摸立即触发
让我写代码:
```javascript
function explode(cx, cy, strength = 1) {
const r = 4;
for (let dj = -r; dj <= r; dj++) {
for (let di = -r; di <= r; di++) {
const i = Math.round(cx + di);
const j = Math.round(cy + dj);
if (i < 1 || i > N || j < 1 || j > N) continue;
const dist = Math.sqrt(di*di + dj*dj);
if (dist > r) continue;
const fall = (1 - dist/r);
// 密度
d[IX(i, j)] += fall * 8 * strength;
// 速度(径向 + 切向旋涡)
if (dist > 0.1) {
const ang = Math.atan2(dj, di);
const rad = Math.cos(ang);
const tan = -Math.sin(ang);
const speed = fall * 60 * strength;
const swirl = fall * 30 * strength * (Math.random() < 0.5 ? 1 : -1);
u[IX(i, j)] += rad * speed + rad * 0 * 0; // wait
// 让我重新写
// 径向方向单位向量
const ex = di / dist;
const ey = dj / dist;
// 切向方向单位向量(垂直于径向)
const tx = -ey;
const ty = ex;
u[IX(i, j)] += ex * speed + tx * swirl;
v[IX(i, j)] += ey * speed + ty * swirl;
}
}
}
}
```
实际上为了让墨水形成漂亮的羽状卷须,应该用更复杂的方法:
- 多个小的旋涡中心(在爆炸周围的环上)
- 每个旋涡注入涡度
让我用 vortex 注入:
```javascript
function explode(cx, cy) {
// 主爆炸:注入高密度
for (let dj = -3; dj <= 3; dj++) {
for (let di = -3; di <= 3; di++) {
const dist = Math.sqrt(di*di + dj*dj);
if (dist < 3) {
const i = Math.round(cx + di);
const j = Math.round(cy + dj);
if (i >= 1 && i <= N && j >= 1 && j <= N) {
d[IX(i, j)] += (1 - dist/3) * 10;
}
}
}
}
// 多个旋涡
const numVortices = 8;
for (let k = 0; k < numVortices; k++) {
const ang = (k / numVortices) * Math.PI * 2 + Math.random() * 0.3;
const r = 3 + Math.random() * 2;
const vx = cx + Math.cos(ang) * r;
const vy = cy + Math.sin(ang) * r;
// 在 注入一个旋涡
const sign = (k % 2 === 0) ? 1 : -1;
addVortex(vx, vy, sign);
}
}
function addVortex(cx, cy, sign) {
const r = 5;
for (let dj = -r; dj <= r; dj++) {
for (let di = -r; di <= r; di++) {
const i = Math.round(cx + di);
const j = Math.round(cy + dj);
if (i < 1 || i > N || j < 1 || j > N) continue;
const dist = Math.sqrt(di*di + dj*dj);
if (dist < 0.1 || dist > r) continue;
// 切向速度
const ex = di / dist;
const ey = dj / dist;
const tx = -ey * sign;
const ty = ex * sign;
const fall = (1 - dist/r);
const speed = fall * 40;
u[IX(i, j)] += tx * speed;
v[IX(i, j)] += ty * speed;
// 顺便加一点径向速度
u[IX(i, j)] += ex * fall * 20;
v[IX(i, j)] += ey * fall * 20;
}
}
}
```
这样会产生漂亮的卷须效果。
OK,让我把所有代码整合起来。
为了性能,我可能需要:
- N = 96
- lin_solve 迭代 4 次
- 不做速度的扩散(只做投影),用密度衰减代替密度扩散
让我估算一下计算量:
- 每帧 N*N = 9216 个网格
- advect: 2 次 = ~18k 操作
- project: lin_solve 4 次迭代 = 4 * N*N * 4 加法 = ~150k 操作
- 还有其他
应该可以跑 60fps。
让我现在开始写完整代码。
```javascript
const N = 96;
const NN = N + 2;
const SZ = NN * NN;
let u = new Float32Array(SZ);
let v = new Float32Array(SZ);
let u0 = new Float32Array(SZ);
let v0 = new Float32Array(SZ);
let d = new Float32Array(SZ);
let d0 = new Float32Array(SZ);
let p = new Float32Array(SZ);
let div = new Float32Array(SZ);
function IX(i, j) { return i + NN * j; }
function set_bnd(b, x) {
for (let i = 1; i <= N; i++) {
x[IX(0, i)] = b === 1 ? -x[IX(1, i)] : x[IX(1, i)];
x[IX(N+1, i)] = b === 1 ? -x[IX(N, i)] : x[IX(N, i)];
x[IX(i, 0)] = b === 2 ? -x[IX(i, 1)] : x[IX(i, 1)];
x[IX(i, N+1)] = b === 2 ? -x[IX(i, N)] : x[IX(i, N)];
}
x[IX(0, 0)] = 0.5 * (x[IX(1, 0)] + x[IX(0, 1)]);
x[IX(0, N+1)] = 0.5 * (x[IX(1, N+1)] + x[IX(0, N)]);
x[IX(N+1, 0)] = 0.5 * (x[IX(N, 0)] + x[IX(N+1, 1)]);
x[IX(N+1, N+1)] = 0.5 * (x[IX(N, N+1)] + x[IX(N+1, N)]);
}
function lin_solve(b, x, x0, a, c, iters) {
const cR = 1.0 / c;
for (let k = 0; k < iters; k++) {
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
const idx = IX(i, j);
x[idx] = (x0[idx] + a * (
x[idx - 1] + x[idx + 1] +
x[idx - NN] + x[idx + NN]
)) * cR;
}
}
set_bnd(b, x);
}
}
function diffuse(b, x, x0, diff, dt, iters) {
const a = dt * diff * N * N;
lin_solve(b, x, x0, a, 1 + 4 * a, iters);
}
function advect(b, d_, d0_, u_, v_, dt) {
const dt0 = dt * N;
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
const idx = IX(i, j);
let x = i - dt0 * u_[idx];
let y = j - dt0 * v_[idx];
if (x < 0.5) x = 0.5;
if (x > N + 0.5) x = N + 0.5;
if (y < 0.5) y = 0.5;
if (y > N + 0.5) y = N + 0.5;
const i0 = x | 0;
const i1 = i0 + 1;
const j0 = y | 0;
const j1 = j0 + 1;
const s1 = x - i0;
const s0 = 1 - s1;
const t1 = y - j0;
const t0 = 1 - t1;
d_[idx] = s0 * (t0 * d0_[IX(i0, j0)] + t1 * d0_[IX(i0, j1)]) +
s1 * (t0 * d0_[IX(i1, j0)] + t1 * d0_[IX(i1, j1)]);
}
}
set_bnd(b, d_);
}
function project(u_, v_, p_, div_) {
const h = 1.0 / N;
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
const idx = IX(i, j);
div_[idx] = -0.5 * h * (
u_[idx + 1] - u_[idx - 1] +
v_[idx + NN] - v_[idx - NN]
);
p_[idx] = 0;
}
}
set_bnd(0, div_);
set_bnd(0, p_);
lin_solve(0, p_, div_, 1, 4, 6);
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
const idx = IX(i, j);
u_[idx] -= 0.5 * (p_[idx + 1] - p_[idx - 1]) / h;
v_[idx] -= 0.5 * (p_[idx + NN] - p_[idx - NN]) / h;
}
}
set_bnd(1, u_);
set_bnd(2, v_);
}
function step(dt) {
// 速度平流
u0.set(u);
v0.set(v);
advect(1, u, u0, u0, v0, dt);
advect(2, v, v0, u0, v0, dt);
project(u, v, p, div);
// 密度平流
d0.set(d);
advect(0, d, d0, u, v, dt);
// 衰减(模拟扩散)
for (let i = 0; i < SZ; i++) {
d[i] *= 0.992;
u[i] *= 0.998;
v[i] *= 0.998;
}
}
```
接下来渲染:
```javascript
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const off = document.createElement('canvas');
off.width = N;
off.height = N;
const offCtx = off.getContext('2d');
const imgData = offCtx.createImageData(N, N);
let W, H;
function resize() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();
function render() {
const data = imgData.data;
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
const didx = IX(i, j);
const pidx = ((j-1) * N + (i-1)) * 4;
let dens = d[didx];
if (dens < 0) dens = 0;
if (dens > 1) dens = 1;
// 基础水色
const wr = 230, wg = 240, wb = 250;
// 墨水黑
const ir = 8, ig = 8, ib = 14;
const r = wr * (1 - dens) + ir * dens;
const g = wg * (1 - dens) + ig * dens;
const b = wb * (1 - dens) + ib * dens;
data[pidx] = r;
data[pidx + 1] = g;
data[pidx + 2] = b;
data[pidx + 3] = 255;
}
}
offCtx.putImageData(imgData, 0, 0);
// 清空主 canvas
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);
// 平滑放大
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
// 居中正方形绘制
const size = Math.min(W, H);
const ox = (W - size) / 2;
const oy = (H - size) / 2;
ctx.drawImage(off, 0, 0, N, N, ox, oy, size, size);
// 顶部高光(径向渐变)
const grad = ctx.createRadialGradient(W/2, -H*0.2, 0, W/2, -H*0.2, H*0.8);
grad.addColorStop(0, 'rgba(255, 255, 255, 0.25)');
grad.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
// 暗角
const grad2 = ctx.createRadialGradient(W/2, H/2, size*0.3, W/2, H/2, size*0.8);
grad2.addColorStop(0, 'rgba(0, 0, 0, 0)');
grad2.addColorStop(1, 'rgba(0, 0, 0, 0.4)');
ctx.fillStyle = grad2;
ctx.fillRect(0, 0, W, H);
}
```
主循环:
```javascript
let lastT = performance.now();
let dropT = 0;
function loop(now) {
let dt = (now - lastT) / 1000;
lastT = now;
if (dt > 0.05) dt = 0.05; // clamp
// 模拟(固定时间步)
const steps = 2;
const subDt = dt / steps;
for (let i = 0; i < steps; i++) {
step(subDt);
}
dropT += dt;
if (dropT > 6) {
explode(N/2, N/2);
dropT = 0;
}
render();
requestAnimationFrame(loop);
}
// 初始触发
explode(N/2, N/2);
requestAnimationFrame(loop);
canvas.addEventListener('click', (e) => {
const rect = canvas.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * N + 1;
const y = ((e.clientY - rect.top) / rect.height) * N + 1;
// 找到正方形区域
const size = Math.min(W, H);
const ox = (W - size) / 2;
const oy = (H - size) / 2;
const lx = (e.clientX - ox) / size * N;
const ly = (e.clientY - oy) / size * N;
explode(Math.max(1, Math.min(N, lx)), Math.max(1, Math.min(N, ly)));
});
```
让我再思考一下"分形卷须"的效果。Navier-Stokes 模拟本身会产生卷曲,但需要:
1. 足够的旋涡(爆炸时注入)
2. 足够的网格分辨率
3. 合适的速度衰减
为了让卷须更明显,我可以:
- 在爆炸时注入多个不同方向的旋涡
- 在 simulate 过程中添加微弱的 curl noise 力(持续搅动)
让我添加 curl noise 扰动:
```javascript
// 简单的伪随机噪声
function noise2(x, y) {
// 简单的 hash
const n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453;
return n - Math.floor(n);
}
function smoothNoise(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const a = noise2(ix, iy);
const b = noise2(ix+1, iy);
const c = noise2(ix, iy+1);
const dd = noise2(ix+1, iy+1);
const u = fx * fx * (3 - 2*fx);
const vv = fy * fy * (3 - 2*fy);
return a + u*(b-a) + vv*(c-a) + u*vv*(a-b-c+dd);
}
function addTurbulence(t, strength) {
const ns = 0.08;
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
// 模拟 curl noise:取两个标量场的旋度
const psi1 = smoothNoise(i*ns + t*0.1, j*ns);
const psi2 = smoothNoise(i*ns + 100, j*ns + t*0.1);
// curl of (psi1, psi2) → 但其实简化版:
// 用 stream function:u = ∂ψ/∂y, v = -∂ψ/∂x
// 这里 ψ 是平滑噪声
// 或者直接加随机扰动
}
}
}
```
实际上,为了简化和性能,我可以用一个简单的方法:每帧在每个网格添加一个基于位置的扰动。
但这样可能会让墨水过快消散。让我先不加,看效果。
另一个增强:在初始爆炸时,加入多个随机方向的小旋涡,模拟墨水入水时的不规则冲击。
让我整理一下最终代码。
我想再增加一些视觉效果:
1. 主 canvas 上叠加一点细微的噪声纹理(让水看起来有质感)
2. 边缘渐变(圆形暗角,让人感觉是俯视杯子)
让我开始写完整代码:
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>墨滴入水</title>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #050810;
cursor: crosshair;
}
canvas {
display: block;
width: 100vw;
height: 100vh;
}
.info {
position: fixed;
top: 24px;
left: 24px;
color: rgba(255,255,255,0.55);
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 12px;
letter-spacing: 0.1em;
text-transform: uppercase;
pointer-events: none;
user-select: none;
}
.title {
position: fixed;
bottom: 24px;
right: 24px;
color: rgba(255,255,255,0.4);
font-family: ui-monospace, monospace;
font-size: 11px;
letter-spacing: 0.2em;
pointer-events: none;
user-select: none;
}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div class="info">CLICK TO DROP INK</div>
<div class="title">INK IN WATER · NAVIER-STOKES</div>
<script>
// === Stable Fluids Simulation ===
const N = 96;
const NN = N + 2;
const SZ = NN * NN;
const u = new Float32Array(SZ);
const v = new Float32Array(SZ);
const u0 = new Float32Array(SZ);
const v0 = new Float32Array(SZ);
const dens = new Float32Array(SZ);
const dens0 = new Float32Array(SZ);
const p = new Float32Array(SZ);
const div = new Float32Array(SZ);
function IX(i, j) { return i + NN * j; }
function set_bnd(b, x) {
for (let i = 1; i <= N; i++) {
x[IX(0, i)] = b === 1 ? -x[IX(1, i)] : x[IX(1, i)];
x[IX(N+1, i)] = b === 1 ? -x[IX(N, i)] : x[IX(N, i)];
x[IX(i, 0)] = b === 2 ? -x[IX(i, 1)] : x[IX(i, 1)];
x[IX(i, N+1)] = b === 2 ? -x[IX(i, N)] : x[IX(i, N)];
}
x[IX(0, 0)] = 0.5 * (x[IX(1, 0)] + x[IX(0, 1)]);
x[IX(0, N+1)] = 0.5 * (x[IX(1, N+1)] + x[IX(0, N)]);
x[IX(N+1, 0)] = 0.5 * (x[IX(N, 0)] + x[IX(N+1, 1)]);
x[IX(N+1, N+1)] = 0.5 * (x[IX(N, N+1)] + x[IX(N+1, N)]);
}
function lin_solve(b, x, x0, a, c, iters) {
const invC = 1.0 / c;
for (let k = 0; k < iters; k++) {
for (let j = 1; j <= N; j++) {
const row = j * NN;
for (let i = 1; i <= N; i++) {
const idx = i + row;
x[idx] = (x0[idx] + a * (
x[idx - 1] + x[idx + 1] +
x[idx - NN] + x[idx + NN]
)) * invC;
}
}
set_bnd(b, x);
}
}
function advect(b, d_, d0_, u_, v_, dt) {
const dt0 = dt * N;
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
const idx = i + j * NN;
let x = i - dt0 * u_[idx];
let y = j - dt0 * v_[idx];
if (x < 0.5) x = 0.5;
if (x > N + 0.5) x = N + 0.5;
if (y < 0.5) y = 0.5;
if (y > N + 0.5) y = N + 0.5;
const i0 = x | 0;
const i1 = i0 + 1;
const j0 = y | 0;
const j1 = j0 + 1;
const s1 = x - i0;
const s0 = 1 - s1;
const t1 = y - j0;
const t0 = 1 - t1;
d_[idx] = s0 * (t0 * d0_[i0 + j0*NN] + t1 * d0_[i0 + j1*NN]) +
s1 * (t0 * d0_[i1 + j0*NN] + t1 * d0_[i1 + j1*NN]);
}
}
set_bnd(b, d_);
}
function project(u_, v_, p_, div_) {
const h = 1.0 / N;
for (let j = 1; j <= N; j++) {
const row = j * NN;
for (let i = 1; i <= N; i++) {
const idx = i + row;
div_[idx] = -0.5 * h * (
u_[idx + 1] - u_[idx - 1] +
v_[idx + NN] - v_[idx - NN]
);
p_[idx] = 0;
}
}
set_bnd(0, div_);
set_bnd(0, p_);
lin_solve(0, p_, div_, 1, 4, 6);
for (let j = 1; j <= N; j++) {
const row = j * NN;
for (let i = 1; i <= N; i++) {
const idx = i + row;
u_[idx] -= 0.5 * (p_[idx + 1] - p_[idx - 1]) / h;
v_[idx] -= 0.5 * (p_[idx + NN] - p_[idx - NN]) / h;
}
}
set_bnd(1, u_);
set_bnd(2, v_);
}
function simulate(dt) {
// Velocity step
u0.set(u);
v0.set(v);
// (skip velocity diffuse for performance)
advect(1, u, u0, u0, v0, dt);
advect(2, v, v0, u0, v0, dt);
project(u, v, p, div);
// Density step
dens0.set(dens);
advect(0, dens, dens0, u, v, dt);
// Dissipation (diffusion approximation + slow fade)
const dFade = 0.992;
const vFade = 0.992;
for (let i = 0; i < SZ; i++) {
dens[i] *= dFade;
u[i] *= vFade;
v[i] *= vFade;
}
}
// === Ink Injection ===
function dropInk(cx, cy, scale = 1) {
// Main blob - dense core
const r0 = 3 * scale;
for (let dj = -Math.ceil(r0); dj <= Math.ceil(r0); dj++) {
for (let di = -Math.ceil(r0); di <= Math.ceil(r0); di++) {
const dist = Math.sqrt(di*di + dj*dj);
if (dist > r0) continue;
const i = Math.round(cx + di);
const j = Math.round(cy + dj);
if (i < 1 || i > N || j < 1 || j > N) continue;
const fall = Math.pow(1 - dist/r0, 1.5);
dens[IX(i, j)] += fall * 6;
}
}
// Radial burst + multiple vortices for fractal tendrils
const burstR = 2.5 * scale;
for (let dj = -Math.ceil(burstR); dj <= Math.ceil(burstR); dj++) {
for (let di = -Math.ceil(burstR); di <= Math.ceil(burstR); di++) {
const dist = Math.sqrt(di*di + dj*dj);
if (dist < 0.1 || dist > burstR) continue;
const i = Math.round(cx + di);
const j = Math.round(cy + dj);
if (i < 1 || i > N || j < 1 || j > N) continue;
const fall = (1 - dist/burstR);
const ex = di / dist;
const ey = dj / dist;
const tx = -ey;
const ty = ex;
const radSpeed = fall * 80 * scale;
const tanSpeed = fall * 30 * scale;
u[IX(i, j)] += ex * radSpeed + tx * tanSpeed;
v[IX(i, j)] += ey * radSpeed + ty * tanSpeed;
}
}
// Add several satellite vortices around the main blob for feather-like tendrils
const numVortices = 6 + Math.floor(Math.random() * 4);
for (let k = 0; k < numVortices; k++) {
const ang = (k / numVortices) * Math.PI * 2 + (Math.random() - 0.5) * 0.5;
const dist = 2.5 + Math.random() * 2;
const vx = cx + Math.cos(ang) * dist;
const vy = cy + Math.sin(ang) * dist;
const sign = (Math.random() < 0.5) ? 1 : -1;
const strength = 30 + Math.random() * 30;
addVortex(vx, vy, sign, strength, 4);
}
}
function addVortex(cx, cy, sign, strength, radius) {
for (let dj = -radius; dj <= radius; dj++) {
for (let di = -radius; di <= radius; di++) {
const dist = Math.sqrt(di*di + dj*dj);
if (dist < 0.1 || dist > radius) continue;
const i = Math.round(cx + di);
const j = Math.round(cy + dj);
if (i < 1 || i > N || j < 1 || j > N) continue;
const ex = di / dist;
const ey = dj / dist;
const tx = -ey * sign;
const ty = ex * sign;
const fall = (1 - dist/radius);
u[IX(i, j)] += tx * fall * strength;
v[IX(i, j)] += ty * fall * strength;
}
}
}
// === Rendering ===
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const off = document.createElement('canvas');
off.width = N;
off.height = N;
const offCtx = off.getContext('2d');
const imgData = offCtx.createImageData(N, N);
const data8 = imgData.data;
// Pre-create light map for shading
const lightMap = new Float32Array(SZ);
function buildLightMap() {
for (let j = 0; j < NN; j++) {
for (let i = 0; i < NN; i++) {
// Light from top
const dy = (j - N/2) / (N/2);
const dx = (i - N/2) / (N/2);
// Top is brighter
const vertical = 1 - dy * 0.5; // top: 1.5, bottom: 0.5
const radial = Math.exp(-(dx*dx + dy*dy) * 1.5);
lightMap[IX(i, j)] = vertical * 0.5 + radial * 0.3;
}
}
}
buildLightMap();
let W, H, dispSize, offX, offY;
function resize() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
dispSize = Math.min(W, H);
offX = (W - dispSize) / 2;
offY = (H - dispSize) / 2;
}
window.addEventListener('resize', resize);
resize();
function render() {
// Build density image
for (let j = 1; j <= N; j++) {
const row = j * NN;
const py = (j - 1) * N;
for (let i = 1; i <= N; i++) {
const idx = i + row;
const pidx = ((j-1) * N + (i-1)) * 4;
let dd = dens[idx];
if (dd < 0) dd = 0;
if (dd > 1) dd = 1;
// Smooth density for softer edges
// (use raw density - smoothing already happens in advection)
const light = lightMap[idx];
// Water color (cool, slightly blue-white)
const wr = 220 + light * 30;
const wg = 232 + light * 20;
const wb = 244 + light * 11;
// Ink color (deep blue-black)
const ir = 6;
const ig = 8;
const ib = 16;
// Blend with density^0.7 for more contrast at edges
const blend = Math.pow(dd, 0.7);
const r = wr * (1 - blend) + ir * blend;
const g = wg * (1 - blend) + ig * blend;
const b = wb * (1 - blend) + ib * blend;
data8[pidx] = r;
data8[pidx + 1] = g;
data8[pidx + 2] = b;
data8[pidx + 3] = 255;
}
}
offCtx.putImageData(imgData, 0, 0);
// Background fill (dark)
ctx.fillStyle = '#04060a';
ctx.fillRect(0, 0, W, H);
// Draw the simulation upscaled
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(off, 0, 0, N, N, offX, offY, dispSize, dispSize);
// Top lighting (soft white glow from above)
const topGrad = ctx.createRadialGradient(W/2, -H*0.15, 0, W/2, -H*0.15, H*0.9);
topGrad.addColorStop(0, 'rgba(255, 252, 245, 0.32)');
topGrad.addColorStop(0.4, 'rgba(255, 250, 240, 0.08)');
topGrad.addColorStop(1, 'rgba(255, 250, 240, 0)');
ctx.fillStyle = topGrad;
ctx.fillRect(0, 0, W, H);
// Circular vignette (gives "glass" feel)
const vGrad = ctx.createRadialGradient(W/2, H/2, dispSize*0.35, W/2, H/2, dispSize*0.75);
vGrad.addColorStop(0, 'rgba(0, 0, 0, 0)');
vGrad.addColorStop(1, 'rgba(0, 0, 0, 0.65)');
ctx.fillStyle = vGrad;
ctx.fillRect(0, 0, W, H);
}
// === Main loop ===
let lastT = performance.now();
let autoDropT = 0;
let autoDropInterval = 5.5;
function loop(now) {
let dt = (now - lastT) / 1000;
lastT = now;
if (dt > 0.05) dt = 0.05;
// Fixed timestep simulation
const simDt = 0.1;
simulate(simDt);
autoDropT += dt;
if (autoDropT > autoDropInterval) {
const cx = N/2 + (Math.random() - 0.5) * N * 0.3;
const cy = N/2 + (Math.random() - 0.5) * N * 0.3;
dropInk(cx, cy, 0.8 + Math.random() * 0.4);
autoDropT = 0;
autoDropInterval = 5 + Math.random() * 3;
}
render();
requestAnimationFrame(loop);
}
// Initial drop
dropInk(N/2, N/2, 1.2);
// Interaction
function dropAt(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
const x = (clientX - rect.left - offX) / dispSize * N;
const y = (clientY - rect.top - offY) / dispSize * N;
if (x >= 1 && x <= N && y >= 1 && y <= N) {
dropInk(x, y, 0.8 + Math.random() * 0.6);
autoDropT = 0;
}
}
canvas.addEventListener('click', (e) => dropAt(e.clientX, e.clientY));
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
if (e.touches[0]) dropAt(e.touches[0].clientX, e.touches[0].clientY);
});
requestAnimationFrame(loop);
</script>
</body>
</html>
```
让我审视一下这个代码:
1. **流体模拟**:标准 Jos Stam stable fluids。应该可以工作。
2. **初始爆炸**:注入密度 + 径向速度 + 多个旋涡。应该产生漂亮的羽状效果。
3. **衰减**:每帧衰减 0.8%,慢慢消散。
4. **渲染**:N x N ImageData → drawImage 放大 → 叠加光照和暗角。
5. **性能**:N=96,每帧操作约 9216 * 几次循环,应该能跑 60fps。
我担心的几点:
1. 衰减太快或太慢?需要调整。
2. 旋涡是否足够产生分形卷须?可能需要更多旋涡或更大尺寸。
3. 渲染颜色是否合适?
让我再仔细看:
**关于密度衰减**:
- 当前 0.992 per frame,假设 60fps,那 1 秒后剩余 0.992^60 ≈ 0.62
- 5 秒后 0.992^300 ≈ 0.09
- 10 秒后几乎全消散
- 这个速度合理
但 simDt=0.1 + 衰减 0.992 per frame,意味着每秒衰减 0.992^60 = 0.62。
而 simulate 是按物理时间来的,所以衰减也按物理时间。
每秒 simulate 60 次(如果 60fps),每次衰减 0.992,一秒后 ≈ 0.62。
这看起来 OK。
**关于旋涡**:
- 6-10 个卫星旋涡 + 中心径向爆炸 + 切向
- 旋涡半径 4,强度 30-60
- 这应该能产生复杂的卷须
**关于颜色**:
- 水色:略带蓝白的 (220-250)
- 墨水:深蓝黑 (6, 8, 16)
- 顶部光照:白色径向
- 应该看起来像一杯清水
让我再优化一下渲染的视觉效果:
为了让墨水看起来更"丝缕状",可以:
1. 在渲染时根据密度的局部梯度增强边缘
2. 添加细微的高频噪声
但简单的渲染可能就够好了。先看看效果。
让我增加一些细节:
- 杯子边缘的高光(圆形高光,模拟玻璃杯壁)
- 中心倒影
让我再调整:
实际上,让我考虑一个问题:N=96 的网格可能不足以表现"分形的卷须"。但放大渲染时,浏览器的图像平滑会让它看起来柔和。这是好的。
为了增强分形效果,可以在初始爆炸时:
- 注入多个层次的旋涡(不同尺寸)
- 添加一些随机噪声扰动
让我增加:
```javascript
function dropInk(cx, cy, scale = 1) {
// Main blob - dense core
const r0 = 3 * scale;
for (let dj = -Math.ceil(r0); dj <= Math.ceil(r0); dj++) {
for (let di = -Math.ceil(r0); di <= Math.ceil(r0); di++) {
const dist = Math.sqrt(di*di + dj*dj);
if (dist > r0) continue;
const i = Math.round(cx + di);
const j = Math.round(cy + dj);
if (i < 1 || i > N || j < 1 || j > N) continue;
const fall = Math.pow(1 - dist/r0, 1.5);
dens[IX(i, j)] += fall * 8 * scale;
}
}
// Radial burst with strong tangential component
const burstR = 3 * scale;
for (let dj = -Math.ceil(burstR); dj <= Math.ceil(burstR); dj++) {
for (let di = -Math.ceil(burstR); di <= Math.ceil(burstR); di++) {
const dist = Math.sqrt(di*di + dj*dj);
if (dist < 0.1 || dist > burstR) continue;
const i = Math.round(cx + di);
const j = Math.round(cy + dj);
if (i < 1 || i > N || j < 1 || j > N) continue;
const fall = Math.pow(1 - dist/burstR, 0.5);
const ex = di / dist;
const ey = dj / dist;
const tx = -ey;
const ty = ex;
const radSpeed = fall * 100 * scale;
const tanSpeed = fall * 50 * scale * (Math.random() < 0.5 ? 1 : -1);
u[IX(i, j)] += ex * radSpeed + tx * tanSpeed;
v[IX(i, j)] += ey * radSpeed + ty * tanSpeed;
}
}
// Multiple satellite vortices at varying distances for fractal tendrils
const numVortices = 8 + Math.floor(Math.random() * 5);
for (let k = 0; k < numVortices; k++) {
const ang = (k / numVortices) * Math.PI * 2 + (Math.random() - 0.5) * 0.7;
const dist = 2 + Math.random() * 4;
const vx = cx + Math.cos(ang) * dist;
const vy = cy + Math.sin(ang) * dist;
const sign = (Math.random() < 0.5) ? 1 : -1;
const strength = 25 + Math.random() * 50;
const radius = 3 + Math.random() * 3;
addVortex(vx, vy, sign, strength, radius);
}
// Add a few small ink "satellites" for irregular tendrils
const numSatellites = 4 + Math.floor(Math.random() * 4);
for (let k = 0; k < numSatellites; k++) {
const ang = Math.random() * Math.PI * 2;
const dist = 2 + Math.random() * 3;
const sx = cx + Math.cos(ang) * dist;
const sy = cy + Math.sin(ang) * dist;
// Small dense spot
const si = Math.round(sx);
const sj = Math.round(sy);
if (si >= 1 && si <= N && sj >= 1 && sj <= N) {
dens[IX(si, sj)] += 2 + Math.random() * 2;
}
}
}
```
这应该能产生更复杂的初始条件。
但要注意,旋涡太多可能会让模拟不稳定。让我保守一些。
让我也想想边界处理。当前是 slip wall(反射边界)。如果墨水到达边界,会反弹。这可能导致墨水在边界堆积。
为了避免这个问题,可以让边界吸收墨水:边界处密度直接衰减或归零。
```javascript
// In set_bnd for density (b=0), force boundary density to 0
if (b === 0) {
for (let i = 1; i <= N; i++) {
x[IX(0, i)] = 0;
x[IX(N+1, i)] = 0;
x[IX(i, 0)] = 0;
x[IX(i, N+1)] = 0;
}
}
```
但标准 Stam 算法是 slip 边界。为了简单,我先用标准版本,但加大衰减让边界问题不明显。
OK,让我整合完整代码并再检查一遍:
我还想增加一个细节:在墨水消散后期,让密度衰减更慢一些(保留淡淡的云雾)。可以用非线性衰减:
```javascript
// Slower fade for small densities (keeps "mist")
dens[i] = dens[i] > 0.1 ? dens[i] * 0.99 : dens[i] * 0.998;
```
这样高密度区域快速消散,低密度区域慢慢消失,形成"淡淡的云雾"效果。
让我把这个加进去。
还有:为了让效果看起来更像墨水(而不是烟雾),渲染颜色应该更深、更黑。当前 (6, 8, 16) 应该 OK。
但密度映射 dd^0.7 可能让浅色区域看起来更暗。让我试试 dd^1 或 dd^0.85。
为了"深而浓的内核、丝缕状半透明的边缘",可以用:
- 密度 < 0.3: 半透明丝缕(α 较低)
- 密度 > 0.5: 深色内核(α 接近 1)
可以用 sigmoid 映射:
```javascript
const blend = 1 / (1 + Math.exp(-dd * 8 + 4));
```
但这样可能太突兀。我用 Math.pow(dd, 0.6) 试试。
实际上,让我用一个更精细的映射:
```javascript
// Three-tone mapping
let blend;
if (dd < 0.05) blend = dd * 0.5; // very light mist
else if (dd < 0.5) blend = 0.025 + (dd - 0.05) * 1.5; // mid
else blend = Math.min(1, 0.7 + (dd - 0.5) * 0.6); // dark core
```
或者更简单的:
```javascript
const blend = dd < 0.5 ? dd * 0.8 : 0.4 + (dd - 0.5) * 1.2;
const clampedBlend = Math.min(1, blend);
```
让我用一个平滑的曲线:
```javascript
// Smoother blend
let blend = Math.pow(dd, 0.5);
blend = Math.min(1, blend * 1.1);
```
试试看。
OK,让我现在写最终版本。
我突然意识到 N=96 可能让模拟不够流畅。让我用 N=80 + 更多次迭代。或者 N=96 + 4 次迭代。
性能估算(每帧):
- advect 速度 (2 次): 2 * 9216 = ~18k 操作
- project: lin_solve 6 次迭代 = 6 * 9216 * 5 = ~280k 操作
- advect 密度: 9216
- 衰减: 9216
- 渲染: 9216 * 5 (RGBA)
总共约 320k 操作 per frame。@ 60fps = 19.2M ops/sec。
现代浏览器 JS 引擎应该轻松处理。我用 N=96 + 6 次迭代 lin_solve。
让我最终确定参数:
- N = 96
- lin_solve velocity: 0 次迭代
- lin_solve pressure: 6 次迭代
- simDt = 0.1
- 衰减:动态(高密度快,低密度慢)
实际上 velocity diffuse 我跳过了,但 density diffuse 也跳过了(用衰减代替)。这是常见简化。
让我再看一下 set_bnd。当 b=0(密度)时,使用 slip 边界(不反射)。这意味着墨水可以"流出"边界但不会反弹。OK。
但速度边界(b=1 或 b=2)是反射边界(用负号),这模拟了容器壁的"无穿透"。这对于"杯子"来说是合理的。
OK,让我整理最终代码:
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>墨滴入水</title>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #050810;
}
canvas {
display: block;
width: 100vw;
height: 100vh;
cursor: crosshair;
}
.ui {
position: fixed;
color: rgba(255,255,255,0.5);
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
pointer-events: none;
user-select: none;
mix-blend-mode: difference;
}
.ui.top { top: 28px; left: 28px; }
.ui.bot { bottom: 28px; right: 28px; text-align: right; }
.ui strong { color: rgba(255,255,255,0.85); font-weight: 600; letter-spacing: 0.15em; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div class="ui top"><strong>INK IN WATER</strong> · CLICK ANYWHERE TO DROP</div>
<div class="ui bot">NAVIER–STOKES · STAM ADVECTION</div>
<script>
(function(){
'use strict';
// === Configuration ===
const N = 96; // Simulation grid
const NN = N + 2;
const SZ = NN * NN;
const SIM_DT = 0.12;
const PRESSURE_ITERS = 6;
const DENS_FADE_HEAVY = 0.985;
const DENS_FADE_LIGHT = 0.997;
const VEL_FADE = 0.992;
const DENS_THRESHOLD = 0.08;
// === State ===
const u = new Float32Array(SZ);
const v = new Float32Array(SZ);
const u0 = new Float32Array(SZ);
const v0 = new Float32Array(SZ);
const dens = new Float32Array(SZ);
const dens0 = new Float32Array(SZ);
const p = new Float32Array(SZ);
const div = new Float32Array(SZ);
// === Helpers ===
function set_bnd(b, x) {
for (let i = 1; i <= N; i++) {
x[0 + i*NN] = b === 1 ? -x[1 + i*NN] : x[1 + i*NN];
x[N+1 + i*NN] = b === 1 ? -x[N + i*NN] : x[N + i*NN];
x[i + 0] = b === 2 ? -x[i + 1*NN] : x[i + 1*NN];
x[i + (N+1)*NN] = b === 2 ? -x[i + N*NN] : x[i + N*NN];
}
x[0] = 0.5 * (x[1] + x[NN]);
x[(N+1)*NN] = 0.5 * (x[1 + (N+1)*NN] + x[N*NN]);
x[N+1] = 0.5 * (x[N] + x[N+1 + NN]);
x[N+1 + (N+1)*NN] = 0.5 * (x[N + (N+1)*NN] + x[N+1 + N*NN]);
}
function lin_solve(b, x, x0, a, c, iters) {
const invC = 1.0 / c;
for (let k = 0; k < iters; k++) {
for (let j = 1; j <= N; j++) {
const row = j * NN;
for (let i = 1; i <= N; i++) {
const idx = i + row;
x[idx] = (x0[idx] + a * (
x[idx - 1] + x[idx + 1] +
x[idx - NN] + x[idx + NN]
)) * invC;
}
}
set_bnd(b, x);
}
}
function advect(b, d_, d0_, u_, v_, dt) {
const dt0 = dt * N;
for (let j = 1; j <= N; j++) {
const row = j * NN;
for (let i = 1; i <= N; i++) {
const idx = i + row;
let x = i - dt0 * u_[idx];
let y = j - dt0 * v_[idx];
if (x < 0.5) x = 0.5;
else if (x > N + 0.5) x = N + 0.5;
if (y < 0.5) y = 0.5;
else if (y > N + 0.5) y = N + 0.5;
const i0 = x | 0;
const i1 = i0 + 1;
const j0 = y | 0;
const j1 = j0 + 1;
const s1 = x - i0;
const s0 = 1 - s1;
const t1 = y - j0;
const t0 = 1 - t1;
d_[idx] = s0 * (t0 * d0_[i0 + j0*NN] + t1 * d0_[i0 + j1*NN]) +
s1 * (t0 * d0_[i1 + j0*NN] + t1 * d0_[i1 + j1*NN]);
}
}
set_bnd(b, d_);
}
function project(u_, v_, p_, div_) {
const h = 1.0 / N;
for (let j = 1; j <= N; j++) {
const row = j * NN;
for (let i = 1; i <= N; i++) {
const idx = i + row;
div_[idx] = -0.5 * h * (
u_[idx + 1] - u_[idx - 1] +
v_[idx + NN] - v_[idx - NN]
);
p_[idx] = 0;
}
}
set_bnd(0, div_);
set_bnd(0, p_);
lin_solve(0, p_, div_, 1, 4, PRESSURE_ITERS);
for (let j = 1; j <= N; j++) {
const row = j * NN;
for (let i = 1; i <= N; i++) {
const idx = i + row;
u_[idx] -= 0.5 * (p_[idx + 1] - p_[idx - 1]) / h;
v_[idx] -= 0.5 * (p_[idx + NN] - p_[idx - NN]) / h;
}
}
set_bnd(1, u_);
set_bnd(2, v_);
}
function simulate(dt) {
u0.set(u);
v0.set(v);
advect(1, u, u0, u0, v0, dt);
advect(2, v, v0, u0, v0, dt);
project(u, v, p, div);
dens0.set(dens);
advect(0, dens, dens0, u, v, dt);
// Nonlinear dissipation: dense ink fades fast, faint mist lingers
for (let i = 0; i < SZ; i++) {
const d = dens[i];
dens[i] = d > DENS_THRESHOLD ? d * DENS_FADE_HEAVY : d * DENS_FADE_LIGHT;
u[i] *= VEL_FADE;
v[i] *= VEL_FADE;
}
}
// === Ink injection ===
function dropInk(cx, cy, scale) {
if (scale === undefined) scale = 1;
// Dense core
const r0 = 2.5 * scale;
const r0c = Math.ceil(r0);
for (let dj = -r0c; dj <= r0c; dj++) {
for (let di = -r0c; di <= r0c; di++) {
const dist = Math.sqrt(di*di + dj*dj);
if (dist > r0) continue;
const i = Math.round(cx + di);
const j = Math.round(cy + dj);
if (i < 1 || i > N || j < 1 || j > N) continue;
const fall = Math.pow(1 - dist/r0, 1.4);
dens[i + j*NN] += fall * 7 * scale;
}
}
// Radial burst + tangential (initial mushroom)
const burstR = 3.5 * scale;
const brc = Math.ceil(burstR);
for (let dj = -brc; dj <= brc; dj++) {
for (let di = -brc; di <= brc; di++) {
const dist = Math.sqrt(di*di + dj*dj);
if (dist < 0.1 || dist > burstR) continue;
const i = Math.round(cx + di);
const j = Math.round(cy + dj);
if (i < 1 || i > N || j < 1 || j > N) continue;
const fall = Math.pow(1 - dist/burstR, 0.6);
const ex = di / dist;
const ey = dj / dist;
const tx = -ey;
const ty = ex;
const rad = fall * 110 * scale;
const tan = fall * 50 * scale * (Math.random() < 0.5 ? 1 : -1);
u[i + j*NN] += ex * rad + tx * tan;
v[i + j*NN] += ey * rad + ty * tan;
}
}
// Satellite vortices for fractal feather-like tendrils
const numV = 7 + (Math.random() * 5) | 0;
for (let k = 0; k < numV; k++) {
const ang = (k / numV) * Math.PI * 2 + (Math.random() - 0.5) * 0.8;
const dist = 2 + Math.random() * 4.5;
const vx = cx + Math.cos(ang) * dist;
const vy = cy + Math.sin(ang) * dist;
const sign = Math.random() < 0.5 ? 1 : -1;
const strength = 30 + Math.random() * 55;
const radius = 2.5 + Math.random() * 2.5;
addVortex(vx, vy, sign, strength, radius);
}
}
function addVortex(cx, cy, sign, strength, radius) {
const rc = Math.ceil(radius);
for (let dj = -rc; dj <= rc; dj++) {
for (let di = -rc; di <= rc; di++) {
const dist = Math.sqrt(di*di + dj*dj);
if (dist < 0.1 || dist > radius) continue;
const i = Math.round(cx + di);
const j = Math.round(cy + dj);
if (i < 1 || i > N || j < 1 || j > N) continue;
const ex = di / dist;
const ey = dj / dist;
const tx = -ey * sign;
const ty = ex * sign;
const fall = 1 - dist/radius;
u[i + j*NN] += tx * fall * strength;
v[i + j*NN] += ty * fall * strength;
}
}
}
// === Rendering ===
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d', { alpha: false });
const off = document.createElement('canvas');
off.width = N;
off.height = N;
const offCtx = off.getContext('2d');
const imgData = offCtx.createImageData(N, N);
const data8 = imgData.data;
// Precomputed light map (top-lit)
const lightR = new Float32Array(N*N);
const lightG = new Float32Array(N*N);
const lightB = new Float32Array(N*N);
function buildLight() {
for (let j = 0; j < N; j++) {
for (let i = 0; i < N; i++) {
const idx = j * N + i;
// Normalized positions, center origin
const nx = (i - N/2) / (N/2);
const ny = (j - N/2) / (N/2);
// Top-light gradient: top brighter
const vert = 0.5 - ny * 0.4; // top ~0.9, bottom ~0.1
// Soft radial highlight from top
const hx = nx;
const hy = ny + 0.6; // light source above
const hdot = hx*hx + hy*hy;
const highlight = Math.exp(-hdot * 1.8) * 0.5;
// Cool blue-white water
const r = 215 + vert * 30 + highlight * 25;
const g = 228 + vert * 22 + highlight * 25;
const b = 240 + vert * 14 + highlight * 15;
lightR[idx] = r;
lightG[idx] = g;
lightB[idx] = b;
}
}
}
buildLight();
let W, H, dispSize, offX, offY;
function resize() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
dispSize = Math.min(W, H) * 0.96;
offX = (W - dispSize) / 2;
offY = (H - dispSize) / 2;
}
window.addEventListener('resize', resize);
resize();
function render() {
for (let j = 1; j <= N; j++) {
const sRow = j * NN;
for (let i = 1; i <= N; i++) {
const sIdx = i + sRow;
const pIdx = ((j-1) * N + (i-1)) * 4;
let dd = dens[sIdx];
if (dd < 0) dd = 0;
// Smooth blend curve: subtle edges, dense core
let blend = dd < 1 ? dd : 1;
blend = Math.pow(blend, 0.7);
// Boost slightly
blend = blend * 1.05;
if (blend > 1) blend = 1;
// Ink color: deep cool black
const ir = 4, ig = 6, ib = 12;
const wr = lightR[(j-1)*N + (i-1)];
const wg = lightG[(j-1)*N + (i-1)];
const wb = lightB[(j-1)*N + (i-1)];
const inv = 1 - blend;
data8[pIdx] = wr * inv + ir * blend;
data8[pIdx + 1] = wg * inv + ig * blend;
data8[pIdx + 2] = wb * inv + ib * blend;
data8[pIdx + 3] = 255;
}
}
offCtx.putImageData(imgData, 0, 0);
// Background
ctx.fillStyle = '#04060a';
ctx.fillRect(0, 0, W, H);
// Subtle "water" backdrop circle
const cx = W/2, cy = H/2;
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, dispSize * 0.5, 0, Math.PI * 2);
ctx.fillStyle = '#0a1018';
ctx.fill();
ctx.clip();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(off, 0, 0, N, N, offX, offY, dispSize, dispSize);
ctx.restore();
// Top soft light
const topGrad = ctx.createRadialGradient(W/2, -H*0.18, 0, W/2, -H*0.05, H*0.85);
topGrad.addColorStop(0, 'rgba(255, 250, 240, 0.35)');
topGrad.addColorStop(0.35, 'rgba(255, 248, 235, 0.12)');
topGrad.addColorStop(1, 'rgba(255, 245, 230, 0)');
ctx.fillStyle = topGrad;
ctx.beginPath();
ctx.arc(cx, cy, dispSize * 0.5, 0, Math.PI * 2);
ctx.fill();
// Glass rim highlight
ctx.lineWidth = 1.5;
ctx.strokeStyle = 'rgba(255, 250, 240, 0.18)';
ctx.beginPath();
ctx.arc(cx, cy, dispSize * 0.5, 0, Math.PI * 2);
ctx.stroke();
// Inner rim soft glow
const rimGrad = ctx.createRadialGradient(cx, cy, dispSize*0.42, cx, cy, dispSize*0.5);
rimGrad.addColorStop(0, 'rgba(0,0,0,0)');
rimGrad.addColorStop(1, 'rgba(0,0,0,0.55)');
ctx.fillStyle = rimGrad;
ctx.beginPath();
ctx.arc(cx, cy, dispSize * 0.5, 0, Math.PI * 2);
ctx.fill();
// Specular highlight on glass
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(-0.5);
ctx.beginPath();
ctx.ellipse(0, -dispSize * 0.32, dispSize * 0.18, dispSize * 0.04, 0, 0, Math.PI * 2);
const spec = ctx.createRadialGradient(0, 0, 0, 0, 0, dispSize * 0.18);
spec.addColorStop(0, 'rgba(255, 250, 240, 0.45)');
spec.addColorStop(1, 'rgba(255, 250, 240, 0)');
ctx.fillStyle = spec;
ctx.fill();
ctx.restore();
⚠ Output was interrupted before a normal finish signal. The content below may be incomplete. Click Rerun to try again.
This run finished without a final content stream. The provider only returned reasoning/intermediate tokens, so the reasoning panel above is expanded instead of showing a blank result.
11.3s
TTFT
58
Reasoning TPS
289s · ≈16,788 tok
—
Output TPS
16,788
Total Tokens (estimated)
Elapsed 301s · Average 58 tok/s · Peak 365 tok/s · Cost ≈$0.074