Java速度能追上Rust吗?有人用2000万粒子实验实测一把

多年来,Java 一直是编程世界里绕不开的老面孔。从 90 年代末的企业级应用到如今的大型服务端系统,Java 凭借“写一次,到处跑”的理念,占据了无数开发者的日常。它稳定、成熟、生态庞大,但也常被人诟病——臃肿、语法冗长、速度不够快。

于是,Java 的性能一直是开发者茶余饭后的讨论话题:它真的慢吗?在现代硬件和新特性的加持下,老牌语言还能跑出惊人的速度吗?本文作者 David Gerrells 用 Java 构建了一个 2000 万粒子的二维粒子模拟,挑战 SIMD、多线程和实时渲染的极限。通过这个例子,我们不仅能看看 Java 在性能上的表现,也能重新认识它作为一门老牌语言的潜力和魅力。

来源:https://dgerrells.com/blog/how-fast-is-java-teaching-an-old-dog-new-tricks

作者 | David Gerrells     责编 | 苏宓
出品 | CSDN(ID:CSDNnews)

Java,这门让人又爱又恨的语言。

很多人提到它的第一反应是:老旧、笨重、臃肿,只是因为某些大企业在上世纪 90 年代被炒作忽悠了,才不得不用它。长久以来,它都背着“企业老古董”的名号。

可现在还真是这样吗?

Java 是否已经僵化在那套老掉牙的面向对象思维中,注定被时代淘汰?还是说,这条“老狗”其实已经学会了一些新把戏?

这次的挑战就是:用尽 Java 的所有新特性,只靠 CPU,模拟尽可能多的粒子。看看能不能——干翻 Rust?

如果你懒得看完整篇,可以直接下载运行包(jar 文件,https://github.com/dgerrells/how-fast-is-it/blob/main/java-land/ParticleSim.jar)。代码也放在 GitHub 上(https://github.com/dgerrells/how-fast-is-it/tree/main/java-land)。你只需要在运行时要加上以下命令,启用 Java 的 Vector API(一个用于高性能计算的新功能)就行:

java --add-modules jdk.incubator.vector --enable-preview -jar ParticleSim.jar

不妨一试!


图片

开场热身

Java 对我来说有点特殊。它是我第一个真正深入学习的编程语言。主要原因,是因为当年我上大学时,学校几乎所有课程都用它作为标准语言。那已经是十多年前的事了。唉,时间真是残酷。

那时候我用 Java 做游戏开发,写过人生第一个多线程的粒子模拟器,还做过一个“致敬”Minecraft 的小游戏(虽然我本人从没玩过 Minecraft)。挺好玩的,当然按今天的标准看那代码相当“痛苦”,但当时的确很有成就感。

然后十年过去,我再一次翻出 Java,却看到一个“SIMD 实验性 API”的新闻。

我当时的反应是:“什么?Java 有 SIMD?太阳从西边出来了?”

没错,太阳真的从西边出来了。Java 现在真的支持 SIMD(Single Instruction Multiple Data,单指令多数据)了,还提供了一个抽象层,让你不用直接面对底层硬件那堆复杂指令。

对于初学者而言,如果你不清楚 SIMD 是啥,可以去搜一下,或者直接问个 AI,都行。

如果你懒得查,我简单说下:SIMD 是一套 CPU 指令,用来让处理器一次同时处理多个数字,而不是一个个来。很多现代编译器其实能自动帮你做这个优化,但不是每次都能做到完美。

麻烦在于:不同 CPU 的 SIMD 指令集各不相同。有的架构只能处理 128 位数据(比如一次 4 个 32 位浮点数),有的能到 256 位,甚至 512 位。这意味着如果你要手动写 SIMD 代码,往往得针对每种 CPU 写一份,或者用别人封装好的库——不过那样就少了点“折腾”的乐趣。

而 Java 的特别之处就在这里:它号称“写一次,到处跑”

那它的 SIMD API 真的能跨平台、跑得又快又稳吗?这就得试试看了。好在我最近刚好用 Rust 和 Swift 写过多线程的 SIMD 粒子模拟器。以前也用 JavaScript 和 Go 写过类似的,但那俩语言本身就比较慢,也没有原生的 SIMD 支持(除非你仔细研究 V8 引擎那一套)。

另外,Java 还有挺多新的“花活”,比如 Lambda 表达式和多线程迭代器——理论上可以让并行编程变得非常轻松。挺棒的。

等会我会具体看看那些 API,但在那之前,我得先搞清楚一件事:

怎么在 Java 里把像素绘制到屏幕上。


图片

在 Java 里绘制

图片

我想保持和之前在 Rust、Swift 做的实验差不多的结构:

写一个小型二维粒子模拟,屏幕上有一个“重力点”,能吸引周围的粒子。每个粒子就是一个像素点。整个过程尽量只用 CPU,GPU 不参与。

那问题来了:在 Java 里,我要怎么在窗口上画像素?

之前在 Rust 里,我用的是一个窗口库,它帮我处理不同操作系统的窗口细节。Swift 则是“纯正苹果血统”,自带一套完善的图形 API,拿来就能用。而 Java 呢?它确实也提供了一组跨平台的 UI API,可以在所有系统上跑。虽然界面看起来不太“原生”,但至少能正常工作。

我上次写 Java UI 的时候,主流还是内置的 Swing 库。那时人们说它要被 JavaFX 替代。

结果现在一看……JavaFX “貌似” 成了标准,但你得手动去下载 jar 包然后通过 Maven 或 Gradle 引入。

所以算了。我还是用 Swing 吧——因为我实在不想折腾 Maven 和 Gradle。

还是想吐槽一下:Java 到了 2025 年了还没有默认包管理器?我就不能像其他语言一样 jfaster add jfx 一键搞定?真让人失望。

当然,这事也没那么严重。因为就算用上 JavaFX,你要在窗口里画像素,最终还是得靠一个叫 BufferedImage 的类。顾名思义,BufferedImage 就是一块存放在内存里的像素缓冲区,你可以在上面“画”,再把它显示到屏幕上。

我就不贴 Swing 的那些啰嗦样板代码了。核心思路是:你创建一个 JFrame(窗口),往里面塞各种 UI 组件。然后写一个自定义的 Panel 类(这里用来显示粒子模拟),继承或实现 Swing 的接口,把自己的绘制逻辑加进去。典型的面向对象玩法。

顺带一提,现在 Java 其实有个更“现代”的 main 函数写法,可以不用写类。可惜它跟 Swing 不兼容。

public class ParticleSim {  public static void main(String[] args) {    new ParticleSim().createAndShowGUI();  }  private void createAndShowGUI() {    JFrame frame = new JFrame("Sips Java");    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);    int width = 1200;    int height = 800;    ParticlePanel particlePanel = new ParticlePanel(width, height);    frame.add(particlePanel);    frame.pack(); // resize child components    frame.setLocationRelativeTo(null); // center    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); // won't actual close without this    frame.setVisible(true); // ya i know, default is false    particlePanel.startSimulation();  }}

ParticlePanel 这个类很长,很啰嗦,很典型的 Java 风格。

一开始,我把逻辑放在事件分发线程上,通过重写 JPanel 的 paint 方法来画画,然后用一个 Timer 定时调用 tick 方法来触发粒子模拟。大概结构就是这样。

⚠️ 提醒一下,这段代码比较长,所以才说“很 Java 风格”。

// importspublic class ParticleSim {  public static void main(String[] args) {    new ParticleSim().createAndShowGUI();  }  private void createAndShowGUI() {    JFrame frame = new JFrame("Vector API Particle Sim (Requires flags)");    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);    int width = 1600;    int height = 900;    ParticlePanel particlePanel = new ParticlePanel(width, height);    frame.add(particlePanel);    frame.pack();    frame.setLocationRelativeTo(null);    frame.setVisible(true);    particlePanel.startSimulation();  }}class ParticlePanel extends JPanel implements ActionListener, MouseListener, MouseMotionListener {  private static final int NUM_PARTICLES = 80_000_000;  private static final int UPDATE_RATE = 1000 / 60;  private float[] positionsX = new float[NUM_PARTICLES];  private float[] positionsY = new float[NUM_PARTICLES];  private float[] velocitiesX = new float[NUM_PARTICLES];  private float[] velocitiesY = new float[NUM_PARTICLES];  private final BufferedImage image;  private final byte[] pixelArray;  private final int panelWidth;  private final int panelHeight;// other input variables  public ParticlePanel(int width, int height) {    // setup state    image = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY);    initializeParticles();    // setup input and time tracking  }  private void initializeParticles() {	  // init particles  }  public void startSimulation() {    timer.start();  }  @Override  public void actionPerformed(ActionEvent e) {    if (isTicking) {        return;    }    isTicking = true;    long now = System.nanoTime();    float deltaTime = (now - lastTickTime) / 1_000_000_000.0f;    lastTickTime = now;    updatePhysics(deltaTime);    renderToPixelArray();    repaint();    frames++;    isTicking = false;  }  private void updatePhysics(float deltaTime) {// update logic  }  private void renderToPixelArray() {    // render  }  @Override  protected void paintComponent(Graphics g) {    super.paintComponent(g);    g.drawImage(image, 00this);  }  // input overloads}

大部分都是标准的 Java Swing 代码。渲染部分稍微有点意思:我把每个粒子映射到一块扁平的像素缓冲区(flat buffer)里,然后再显示出来。

private void renderToPixelArray() {  final int w = panelWidth;  final int h = panelHeight;  final byte empty = 0;  Arrays.fill(pixelArray, empty);  for (int i = 0; i < NUM_PARTICLES; i++) {    int px = (int) positionsX[i];    int py = (int) positionsY[i];    int index = py * w + px;    // stay in bounds.    if (px < 0 || px >= w || py < 0 || py >= h) {        continue;    }    int lu = pixelArray[index] & 0xFF;    lu = Math.min(255, lu + 1);    pixelArray[index] = (byte) lu;  }}

真正有意思的部分是 updatePhysics 方法——这里才是“魔法发生的地方”。


图片

不同 CPU,SIMD 通道大小不同

记住,SIMD 在不同硬件上支持的“通道大小”(lane size)不同。为了效率,最好用支持的最大通道大小Java 的 SIMD API 通过定义 “species”(种类) 来处理这个问题。每个 species 对应一种数据类型,比如 float 或 int,然后你就可以根据这个 species 的大小,把数据加载到 Vector 类型中,或者从 Vector 中取出数据。

大概就是这样工作的:

private static final VectorSpecies<Float> F_SPECIES = FloatVector.SPECIES_PREFERRED;private static final int LANE_SIZE = F_SPECIES.length();private static final FloatVector PULL_VEC = FloatVector.broadcast(F_SPECIES, 500f);

这就让代码可以比较通用地适配不同硬件。通常推荐使用 preferred,据说性能最好。还有一个 MAX,会用最大的通道大小,但不一定性能最好。在我的 M1 上,preferred 和 MAX 都输出 4 个浮点数(128 位)。我就用 preferred 就好。

顺便说一句,我不知道为什么,但我特别喜欢这里叫 species 这个名字,真让人忍俊不禁。我能想象一大群人开会讨论,最后才定下这个名字的场景。


图片

多线程 SIMD

我一不小心就想跳过单线程,直接用 Java 的 stream API 弄多线程 SIMD 了。思路是这样的:用 IntStream.range 把粒子按照最大通道数切块,然后对这些块进行并行迭代,实现多线程计算。

final int w = this.panelWidth;final int h = this.panelHeight;final float wFloat = (float) w;final float hFloat = (float) h;final FloatVector DT_VEC = FloatVector.broadcast(F_SPECIES, deltaTime);// var works toofinal var MOUSE_X_VEC = FloatVector.broadcast(F_SPECIES, (float) mousePosition.x);final var MOUSE_Y_VEC = FloatVector.broadcast(F_SPECIES, (float) mousePosition.y);// more vectorsfinal int VECTOR_CHUNKS = NUM_PARTICLES / LANE_SIZE;final int SCALAR_START_INDEX = VECTOR_CHUNKS * LANE_SIZE;IntStream.range(0, VECTOR_CHUNKS).parallel().forEach(chunkIndex -> {  int i = chunkIndex * LANE_SIZE;  var px = FloatVector.fromArray(F_SPECIES, positionsX, i);  var py = FloatVector.fromArray(F_SPECIES, positionsY, i);  var vx = FloatVector.fromArray(F_SPECIES, velocitiesX, i);  var vy = FloatVector.fromArray(F_SPECIES, velocitiesY, i);  if (mouseIsPressed) {    var dx = MOUSE_X_VEC.sub(px);    var dy = MOUSE_Y_VEC.sub(py);    var distSq = dx.mul(dx).add(dy.mul(dy));    var gravityMask = distSq.compare(GT, MIN_DIST_SQ_VEC);    if (gravityMask.anyTrue()) {      var dist = distSq.sqrt();      var forceX = dx.div(dist).mul(PULL_SCALED_VEC);      var forceY = dy.div(dist).mul(PULL_SCALED_VEC);      vx = vx.add(forceX, gravityMask);      vy = vy.add(forceY, gravityMask);    }  }  vx = vx.mul(FRICTION_DT_VEC);  vy = vy.mul(FRICTION_DT_VEC);  px = px.add(vx.mul(DT_VEC));  py = py.add(vy.mul(DT_VEC));  var maskLeftX = px.compare(LT, ZERO_VEC);  var maskRightX = px.compare(GT, W_VEC);  var maskBounceX = maskLeftX.or(maskRightX);  vx = vx.blend(vx.mul(BOUNCE_MULTIPLIER_VEC), maskBounceX);  px = px.blend(ZERO_VEC, maskLeftX);  px = px.blend(W_VEC, maskRightX);  var maskTopY = py.compare(LT, ZERO_VEC);  var maskBottomY = py.compare(GT, H_VEC);  var maskBounceY = maskTopY.or(maskBottomY);  vy = vy.blend(vy.mul(BOUNCE_MULTIPLIER_VEC), maskBounceY);  py = py.blend(ZERO_VEC, maskTopY);  py = py.blend(H_VEC, maskBottomY);  px.intoArray(positionsX, i);  py.intoArray(positionsY, i);  vx.intoArray(velocitiesX, i);  vy.intoArray(velocitiesY, i);});

多线程的使用方式和 Rust、Swift 非常相似,更重要的是,它真的能跑起来。Java 的 var 关键字让代码看起来更简洁一些,但我这种老手通常还是喜欢写显式类型。这里有个问题,细心的人可能已经注意到了:粒子数量不一定能被 SIMD 的通道数整除。这意味着会有剩余的粒子暂时没被处理。

处理方法有两种:

  1. 继续用向量方式处理,给空余通道填充占位;

  2. 用一个非向量化的循环单独处理剩下的粒子。

我选了第二种方式,但为了简洁,这里就不展示代码了。

效果是可以的,这里展示 2000 万粒子的模拟效果。

速度不快,大概只能跑到 20 帧每秒,而且看起来也不太吸引人。我想我知道原因了。


图片

大改造

慢的原因之一不是模拟本身,而是渲染。目前代码是随机访问像素缓冲区的,这样效率很低。在 Rust 和 Swift 的版本里,大部分时间也是耗在这里,Java 也不例外。

除了把这些操作分摊到更多线程之外,几乎没什么优化空间。多线程能提速,但不能解决缓存访问冲突的问题。

还有一些其他问题。首先,渲染循环现在运行在事件分发线程上,这必须改掉。虽然改了之后输入处理会更复杂。整体效果看起来也比较单调、无聊。

在 Rust 和 Swift 版本里,我是通过按粒子相对于窗口的 x/y 距离缩放 RGB 颜色来给像素上色的。

这次我想换个玩法:给每个粒子分配一个独立颜色,之后再加点花哨的效果,同时支持窗口缩放、平移,还能让粒子速度变慢,提升使用体验。

我会跳过中间的琐碎步骤,直接说最终方案。

第一步:脱离事件分发线程

我打算用 Java 的新 lambda 特性,把一个函数传给线程对象来执行。

public void startSimulation() {  if (!running) {    running = true;    gameLoopThread = new Thread(this::gameLoop);    gameLoopThread.start();  }}

游戏循环会用一个“忙等待”的 while 循环——每次循环会暂停一小段时间,然后再检查是否可以渲染下一帧。我希望在这小段暂停时间里,从事件分发线程轮询输入,而不是等到下一帧渲染时才处理。这样可以让模拟更加响应及时。不过,有些事件仍然需要按照渲染帧率来处理,比如平移,因为这类操作需要考虑时间差来计算移动距离。

private void gameLoop() {  lastTickTime = System.nanoTime();  while (running) {    long now = System.nanoTime();    long timeElapsed = now - lastTickTime;    this.processInputRequests();    if (timeElapsed >= NS_PER_TICK) {      float deltaTime = timeElapsed / (float) NS_PER_SECOND;      lastTickTime = now;// little gross this      for (var key : this.keysPressed) {        float speed = 500;        if (this.velInputMap.containsKey(key)) {          this.panDeltaInput.x += velInputMap.get(key).x * speed * deltaTime;          this.panDeltaInput.y += velInputMap.get(key).y * speed * deltaTime;        }      }      long tickStart = System.nanoTime();      tick(deltaTime);      long tickEnd = System.nanoTime();      long tickDuration = (tickEnd - tickStart);      long renderStart = System.nanoTime();      render();      Graphics2D g = (Graphics2D) getGraphics();      g.drawImage(image, 00this);      g.dispose();      Toolkit.getDefaultToolkit().sync();      frames++;      long renderEnd = System.nanoTime();      long renderDuration = (renderEnd - renderStart);      // log frame time info    } else {      try {        Thread.sleep(1);      } catch (InterruptedException e) {      // handle error      }    }  }}

关键的一行是:

if (timeElapsed >= NS_PER_TICK)

它用来判断是否需要渲染下一帧

这样做还可以让渲染速度超过 60 帧/秒——对于那些性能超强的电脑或者粒子数量较少的情况,非常有用。

你可能会注意到,我现在是在更新像素后再画图

我关闭了 Swing 自带的被动渲染(passive rendering),改为主动渲染。这样逻辑更简单,也更易控制。

接下来就是 tick 函数了。

private void tick(float deltaTime) {  final int vectorizedEndIndex = (NUM_PARTICLES / LANE_SIZE) * LANE_SIZE;  final int chunkSize = vectorizedEndIndex / CPU_COUNT;  final var futures = new ArrayList<Future<?>>(CPU_COUNT);  // copy input data  final int panDx = this.panDeltaInput.x;  final int panDy = this.panDeltaInput.y;  final float vScale = this.isSlowDownRequested ? this.inputVelScale : 0f;  // reset for input thread, access is synced  this.panDeltaInput.x = 0;  this.panDeltaInput.y = 0;  for (int i = 0; i < CPU_COUNT; i++) {    int start = i * chunkSize;    int end = (i == CPU_COUNT - 1) ? vectorizedEndIndex : start + chunkSize;    ParticleUpdateTask task = tasks[i];    task.updateParams(i, start, end, this, deltaTime, panDx, panDy, vScale);    futures.add(executorService.submit(task));  }  for (Future<?> future : futures) {    try {      future.get();    } catch (InterruptedException | ExecutionException e) {      e.printStackTrace();    }  }}

这里有几个值得注意的点:

首先,借鉴 Rust 的做法,为了避免在多线程间频繁同步或加锁变量,我在进入核心模拟循环前,会先拷贝一份最新的输入数据

大多数输入值会在主线程和事件分发线程之间同步。

这种加锁在非核心路径(非 hot path)下没问题,但对于粒子更新这种“热路径”,拷贝数据是必须的,否则性能会受很大影响。

另外,我对程序做了性能分析,发现大约 20% 的时间花在了 Java 的线程管理内部。这其实有些预料之中:

在 Swing、Rust 和 Go 中,使用那些花哨的线程迭代器会带来额外开销,既有内存压力,也有处理时间消耗。

相比之下,创建一个工作线程池并重复利用线程通常更快。

这也是一个机会,可以用 Java 的 Future 对象做一点异步控制。这里我没用太多,但确实挺有趣的。

ParticleUpdateTask 的代码大体没变,只是做了一些小调整。下面是几个关键部分。

class ParticleUpdateTask implements Runnable {  // boiler plate variables, constructor, and param update function  @Override  public void run() {    // many local variables    for (int i = startIndex; i < vectorEndIndex; i += LANE_SIZE) {      FloatVector px = FloatVector.fromArray(F_SPECIES, positionsX, i);      FloatVector py = FloatVector.fromArray(F_SPECIES, positionsY, i);      FloatVector vx = FloatVector.fromArray(F_SPECIES, velocitiesX, i);      FloatVector vy = FloatVector.fromArray(F_SPECIES, velocitiesY, i);      if (mouseIsPressed) {        FloatVector dx = MOUSE_X_VEC.sub(px);        FloatVector dy = MOUSE_Y_VEC.sub(py);        FloatVector distSq = dx.mul(dx).add(dy.mul(dy));        var gravityMask = distSq.compare(GT, minPullDist);        if (gravityMask.anyTrue()) {          FloatVector dist = distSq.sqrt();          FloatVector forceX = dx.div(dist).mul(gf);          FloatVector forceY = dy.div(dist).mul(gf);          vx = vx.add(forceX, gravityMask);          vy = vy.add(forceY, gravityMask);        }      }      px = px.add(vx.mul(deltaTime)).add(ox);      py = py.add(vy.mul(deltaTime)).add(oy);      vx = vx.mul(FRICTION_DT_VEC);      vy = vy.mul(FRICTION_DT_VEC);      px.intoArray(positionsX, i);      py.intoArray(positionsY, i);      vx.intoArray(velocitiesX, i);      vy.intoArray(velocitiesY, i);    }    // non vectorized version    var pixels = panel.threadPixelBuffers[id];    Arrays.fill(pixels, 0);    for (int i = startIndex; i < endIndex; i++) {      int px = (int) Math.min(Math.max(positionsX[i], 0), w - 1);      int py = (int) Math.min(Math.max(positionsY[i], 0), h - 1);      int index = py * w + px;      pixels[index] = colors[i];    }  }}

我去掉了粒子的边缘反弹逻辑,并进一步整理了 SIMD 代码。最重要的是,我给每个线程分配了一个本地像素缓冲区来绘制粒子。

我尝试过用 SIMD 优化像素缓冲区的截断(clamping)和索引计算,但结果反而更慢。还试过用 Java API 中一些更高级的 SIMD 函数,比如 fma,结果同样不如预期。

最后一个重大改动是:把每个工作线程的本地像素缓冲区直接累加到 BufferedImage 的数据里,这样合并更高效。

private void render() {  int[] buff = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();  Arrays.fill(buff, 0);  final int PIXEL_COUNT = buff.length;  IntStream.range(0, CPU_COUNT).parallel().forEach(chunkIndex -> {    int chunkSize = PIXEL_COUNT / CPU_COUNT;    int start = chunkIndex * chunkSize;    int end = (chunkIndex == CPU_COUNT - 1) ? PIXEL_COUNT : start + chunkSize;    for (int i = start; i < end; i++) {      int color = 0;      for (int localIndex = 0; localIndex < CPU_COUNT; localIndex++) {        int col = threadPixelBuffers[localIndex][i];        if (col != 0) {          color = col;          break;        }      }      buff[i] = (0xFF << 24) | color;    }  });}

我对线程本地像素缓冲区采用了“最后写入者胜出”的策略,而在合并时则是“第一次写入者胜出”。虽然让所有线程直接写同一个像素缓冲区也能工作,但会引入一些缓存一致性问题。给每个线程分配独立缓冲区虽然会增加内存占用,但性能更稳定。

说到内存:Java 的基线开销相当高,1 百万粒子大概需要 300ms。不过它的扩展表现和 Rust 基本一致,1 亿粒子的耗时和 Rust 差不多,再加上 300MB 的基线开销

性能表现如何?先等等……


图片

像素的完美布局

我加了一个 colors 数组来存储粒子颜色,想让它填充出一些有趣的效果。一个朋友提议:根据粒子相对于中心点的角度上色,模拟一个色轮效果。我觉得这个主意挺酷的,但我想用 OKLAB 的方式来实现。

我对颜色格式了解不多,而且时间有限,所以让 AI 帮我写了段代码:根据粒子相对于屏幕中心点的角度,计算 OKLAB 色调。是不是完全正确?我怀疑。但我本想用现成库来做……你知道的,Gradle 的折腾。

我还增加了几种其他布局方式:一种按圆形排列(这段大部分代码是我写的)。另一种随机分布 N 个点,然后根据粒子到这些点的距离上色(这段我没写,但效果挺有意思)

好了,这里来个演示。

我觉得效果非常棒。你可以右键拖动来平移,或者用 WASD 控制视角。空格键可以让粒子减速。我特别喜欢在这里玩耍。如果我把用来调试 OKLAB 上色的时间,拿来像玩这些交互那样投入,可能早就不用 AI 自己搞定了。但唉,我脑子里那个贪玩的小恶魔必须得到满足。

说到这个“贪玩的小恶魔”,我选择用 32 位整型表示粒子颜色,而不是用单字节或简单的开/关标志,是有原因的。

如果我根据用户定义的图片来给粒子上色呢?我可以根据粒子数量放大或缩小图片,还要注意把多余的粒子放在重复的位置。想想就很酷,对吧?

好吧,现在按数字 4,选择一张图片。

这里展示的是 2000 万粒子,用的是我最喜欢的一张图。

真是太酷了。它会根据粒子数量放大或缩小图片。这张图是 4K 分辨率,被放大到 2000 万粒子,几乎是原来的三倍大小。

看着效果,我都想加个缩放功能了,但我觉得该停手了。

那么,性能如何呢?


图片

Java 究竟有多快?

我会拿 Rust 来做对比,因为它目前表现最强。不过要注意,每个版本并不是完全一一对应的。Rust 每个像素只写 1 个字节,但相比于对像素缓冲区的随机访问,内存速度并不是渲染的瓶颈。Java 的 Vector API 仍处于实验性(incubator)阶段,未来可能会有所改进。

下面是 M1 Air 上的测试结果。

图片

你看这个结果。Rust 在大多数规模下速度大约是 Java 的两倍

两个版本都是在 tick 中填充像素缓冲区,这也是为什么渲染在不同粒子规模下变化不大。主要耗时是累加缓冲区并把它显示到屏幕,这个时间基本是固定的。

有意思的是,在 1 百万粒子时,Rust 似乎渲染时间更长,我这边多次测试都能复现,但原因我不清楚。

Rust 的内存分配速度快得多。这是因为 Java 都是在堆上分配内存。虽然可以用堆外内存(off-heap),创建速度通常快 2–3 倍,但我发现性能略微比堆上慢一些。差距不大,只有几个百分点,而且主要是在访问数据而非写入数据时的差距。

自从我上次用 Java 以来,它已经进步很多。没有借用检查的情况下,它的速度也只有 Rust 的一半左右

可惜的是,虽然 Java 语言本身更友好、更易写,但整个生态系统仍有很多不足。

如果今天我要再写一款游戏,阻止我选择 Java 的,不是语言本身,而是搭建一个还算合理的构建系统、引入几个小库竟然这么麻烦

我记得以前为了让所有 Vorbis 和 OpenGL 的 jar、dll 等都能正常工作,要写一个复杂的构建脚本,过程超级折腾,而这一点至今几乎没有改善。

我想,“老狗”是可以学新把戏的,但如果它还是在同一个灰尘满满、没有草地、也没有大黄球的老狗公园里混,它也没机会展示什么新本领。

不过,Java 永远在我心里占有一席之地。