1 /*
2 * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved.
3 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4 *
5 * This code is free software; you can redistribute it and/or modify it
6 * under the terms of the GNU General Public License version 2 only, as
7 * published by the Free Software Foundation.
8 *
9 * This code is distributed in the hope that it will be useful, but WITHOUT
10 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
12 * version 2 for more details (a copy is included in the LICENSE file that
13 * accompanied this code).
14 *
15 * You should have received a copy of the GNU General Public License version
16 * 2 along with this work; if not, write to the Free Software Foundation,
17 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
18 *
19 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
20 * or visit www.oracle.com if you need additional information or have any
21 * questions.
22 */
23
24 package normmap;
25
26 import javax.imageio.ImageIO;
27 import javax.swing.JFrame;
28
29 import java.awt.Graphics;
30 import java.awt.Graphics2D;
31 import java.awt.Color;
32 import java.awt.image.BufferedImage;
33 import java.awt.image.DataBufferInt;
34 import java.io.IOException;
35 import java.util.Random;
36 import javax.swing.JPanel;
37 import java.awt.Font;
38
39 import java.util.Arrays;
40
41 /**
42 * Based on a demo presented at JVMLS 2025 conference by Emanuel Peter, when giving
43 * The rest of this comment is based on Emanuel's original code.
44 *
45 * A talk about Auto-Vectorization in HotSpot, see:
46 * https://inside.java/2025/08/16/jvmls-hotspot-auto-vectorization/
47 *
48 * If you want to disable the auto-vectorizer, you can run:
49 * java -XX:-UseSuperWord NormalMapping.java
50 *
51 * On x86, you can also play with the UseAVX flag:
52 * java -XX:UseAVX=1 NormalMapping.java
53 *
54 * The motivation for JVMLS 2025 was to present something that vectorizes
55 * in an "embarassingly parallel" way. It should be something that C2's
56 * SuperWord Auto Vectorizer could already do for many JDK releases,
57 * and also has some visual appeal. I decided to use normal mapping, see:
58 * https://en.wikipedia.org/wiki/Normal_mapping
59 *
60 * At the conference, I only had the version that loads a normal map
61 * from an image. I now also added some "generated" cases, which are
62 * created from 2d height functions, and then converted to normal
63 * maps. This allows us to show more "surfaces" without having to
64 * store the images for all those cases.
65 *
66 * If you are interested in understanding the components, then look at these:
67 * - computeLight: the normal mapping "shader / kernel".
68 * - generateNormals / computeNormals: computing normals from height functions.
69 * - main: setup and endless-loop that triggers normals to be swapped periodically.
70 * - MyDrawingPanel: drawing all the parts to the screen.
71 */
72 public class Main {
73 public static Random RANDOM = new Random();
74
75 static void main(String[] args) {
76 System.out.println("Welcome to the Normal Mapping Demo!");
77 // Create an application state with 5 lights.
78 State state = new State(5);
79
80 // Set up a panel we can draw on, and put it in a window.
81 System.out.println("Setting up Window...");
82 MyDrawingPanel panel = new MyDrawingPanel(state);
83 JFrame frame = new JFrame("Normal Mapping Demo (Auto-Vectorization)");
84 frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
85 frame.setSize(2000, 1000);
86 frame.add(panel);
87 frame.setVisible(true);
88 System.out.println("Running Demo...");
89
90 try {
91 // Tight loop where we redraw the panel as fast as possible.
92 int count = 0;
93 while (true) {
94 Thread.sleep(1);
95 state.update();
96 panel.repaint();
97 if (count++ > 500) {
98 count = 0;
99 state.nextNormals();
100 }
101 }
102 } catch (InterruptedException e) {
103 System.out.println("Interrupted, terminating demo.");
104 } finally {
105 System.out.println("Shut down demo.");
106 frame.setVisible(false);
107 frame.dispose();
108 }
109 }
110
111 /* public static File getLocalFile(String name) {
112 // If we are in JTREG IR testing mode, we have to get the path via system property,
113 // if it is run in stand-alone that property is not available, and we can load
114 // via getResource.
115 String file = //System.getProperty("test.src",
116 "/Users/grfrost/github/babylon-grfrost-fork/hat/examples/normmap/src/main/resources/images/"+name;
117 System.out.println("file = "+file);
118 return new File(file);
119
120 } */
121
122 public static BufferedImage loadImage(String resourcePath) {
123 try {
124 var inputStream = Main.class.getResourceAsStream(resourcePath);
125 return ImageIO.read(inputStream);
126 // return ImageIO.read(file);
127 } catch (IOException e) {
128 throw new RuntimeException("Could not load: ", e);
129 }
130 }
131
132 public static class Light {
133 public float x = 0.5f;
134 public float y = 0.5f;
135 private float dx;
136 private float dy;
137
138 private float h;
139 public float r;
140 public float g;
141 public float b;
142
143 Light() {
144 this.h = RANDOM.nextFloat();
145 }
146
147 // Random movement of the Light
148 public void update() {
149 // Random acceleration with dampening.
150 dx *= 0.99;
151 dy *= 0.99;
152 dx += RANDOM.nextFloat() * 0.001 - 0.0005;
153 dy += RANDOM.nextFloat() * 0.001 - 0.0005;
154 x += dx;
155 y += dy;
156
157 // Boounce off the walls.
158 if (x < 0) { dx = +Math.abs(dx); }
159 if (x > 1) { dx = -Math.abs(dx); }
160 if (y < 0) { dy = +Math.abs(dy); }
161 if (y > 1) { dy = -Math.abs(dy); }
162
163 // Rotate the hue -> gets us nice rainbow colors.
164 h += 0.001 + RANDOM.nextFloat() * 0.0002;
165 Color c = Color.getHSBColor(h, 1f, 1f);
166 r = (1f / 256f) * c.getRed();
167 g = (1f / 256f) * c.getGreen();
168 b = (1f / 256f) * c.getBlue();
169 }
170 }
171
172 public static class State {
173 private static final int sizeX = 1000;
174 private static final int sizeY = 1000;
175
176 public Light[] lights;
177 private int nextNormalsId = 0;
178
179 public BufferedImage normals;
180 public float[] coordsX;
181 public float[] coordsY;
182 public float[] normalsX;
183 public float[] normalsY;
184 public float[] normalsZ;
185
186 public BufferedImage output;
187 public BufferedImage output_2;
188 public int[] outputRGB;
189 public int[] outputRGB_2;
190
191 public long lastTime;
192 public float fps;
193
194 float luminosityCorrection = 1f;
195
196 public State(int numberOfLights) {
197 lights = new Light[numberOfLights];
198 for (int i = 0; i < lights.length; i++) {
199 lights[i] = new Light();
200 }
201
202 // Coordinates
203 this.coordsX = new float[sizeX * sizeY];
204 this.coordsY = new float[sizeX * sizeY];
205 for (int y = 0; y < sizeY; y++) {
206 for (int x = 0; x < sizeX; x++) {
207 this.coordsX[y * sizeX + x] = x * (1f / sizeX);
208 this.coordsY[y * sizeX + x] = y * (1f / sizeY);
209 }
210 }
211
212 nextNormals();
213
214 // Double buffered output images, where we render to.
215 // Without double buffering, we would get some flickering effects,
216 // because we would be concurrently updating the buffer and drawing it.
217 this.output = new BufferedImage(sizeX, sizeY, BufferedImage.TYPE_INT_RGB);
218 this.output_2 = new BufferedImage(sizeX, sizeY, BufferedImage.TYPE_INT_RGB);
219 this.outputRGB = ((DataBufferInt) output.getRaster().getDataBuffer()).getData();
220 this.outputRGB_2 = ((DataBufferInt) output_2.getRaster().getDataBuffer()).getData();
221
222 // Set up the FPS tracker
223 lastTime = System.nanoTime();
224 }
225
226 public void nextNormals() {
227 switch (nextNormalsId) {
228 case 0 -> setNormals(loadNormals("normal_map.png"));
229 case 1 -> setNormals(generateNormals("heart"));
230 case 2 -> setNormals(generateNormals("hex"));
231 case 3 -> setNormals(generateNormals("cone"));
232 case 4 -> setNormals(generateNormals("ripple"));
233 case 5 -> setNormals(generateNormals("hill"));
234 case 6 -> setNormals(generateNormals("ripple2"));
235 case 7 -> setNormals(generateNormals("cones"));
236 case 8 -> setNormals(generateNormals("spheres"));
237 case 9 -> setNormals(generateNormals("donut"));
238 default -> throw new RuntimeException();
239 }
240 nextNormalsId = (nextNormalsId + 1) % 10;
241 }
242
243 public BufferedImage loadNormals(String name) {
244 // Extract normal values from RGB image
245 // The loaded image may not have the desired INT_RGB format, so first convert it
246 BufferedImage normalsLoaded = loadImage("/images/"+name);
247 BufferedImage buf = new BufferedImage(sizeX, sizeY, BufferedImage.TYPE_INT_RGB);
248 buf.getGraphics().drawImage(normalsLoaded, 0, 0, null);
249 return buf;
250 }
251
252 public void setNormals(BufferedImage buf) {
253 this.normals = buf;
254
255 int[] normalsRGB = ((DataBufferInt) this.normals.getRaster().getDataBuffer()).getData();
256 this.normalsX = new float[sizeX * sizeY];
257 this.normalsY = new float[sizeX * sizeY];
258 this.normalsZ = new float[sizeX * sizeY];
259 for (int y = 0; y < sizeY; y++) {
260 for (int x = 0; x < sizeX; x++) {
261 this.coordsY[y * sizeX + x] = y * (1f / sizeY);
262 int normal = normalsRGB[y * sizeX + x];
263 // RGB values in range [0 ... 255]
264 int nr = (normal >> 16) & 0xff;
265 int ng = (normal >> 8) & 0xff;
266 int nb = (normal >> 0) & 0xff;
267
268 // Map range [0..255] -> [-1 .. 1]
269 float nx = ((float)nr) * (1f / 128f) - 1f;
270 float ny = ((float)ng) * (1f / 128f) - 1f;
271 float nz = ((float)nb) * (1f / 128f) - 1f;
272
273 this.normalsX[y * sizeX + x] = -nx;
274 this.normalsY[y * sizeX + x] = ny;
275 this.normalsZ[y * sizeX + x] = nz;
276 }
277 }
278 }
279
280 interface HeightFunction {
281 // x and y should be in [0..1]
282 double call(double x, double y);
283 }
284
285 public BufferedImage generateNormals(String name) {
286 System.out.println(" generate normals for: " + name);
287 return computeNormals((double x, double y) -> {
288 // Scale out, so we see a little more
289 x = 10 * (x - 0.5);
290 y = 10 * (y - 0.5);
291
292 // A selection of "height functions":
293 return switch (name) {
294 case "cone" -> 0.1 * Math.max(0, 2 - Math.sqrt(x * x + y * y));
295 case "heart" -> {
296 double heart = Math.abs(Math.pow(x * x + y * y - 1, 3) - x * x * Math.pow(-y, 3));
297 double decay = Math.exp(-(x * x + y * y));
298 yield 0.1 * heart * decay;
299 }
300 case "hill" -> 0.5 * Math.exp(-(x * x + y * y));
301 case "ripple" -> 0.01 * Math.sin(x * x + y * y);
302 case "ripple2" -> 0.3 * Math.sin(x) * Math.sin(y);
303 case "donut" -> {
304 double d = Math.sqrt(x * x + y * y) - 2;
305 double i = 1 - d*d;
306 yield (i >= 0) ? 0.1 * Math.sqrt(i) : 0;
307 }
308 case "hex" -> {
309 double f = 3.0;
310 double a = Math.cos(f * x);
311 double b = Math.cos(f * (-0.5 * x + Math.sqrt(3) / 2.0 * y));
312 double c = Math.cos(f * (-0.5 * x - Math.sqrt(3) / 2.0 * y));
313 yield 0.03 * (a + b + c);
314 }
315 case "cones" -> {
316 double scale = 2.0;
317 double r = 0.8;
318 double cx = scale * (Math.floor(x / scale) + 0.5);
319 double cy = scale * (Math.floor(y / scale) + 0.5);
320 double dx = x - cx;
321 double dy = y - cy;
322 double d = Math.sqrt(dx * dx + dy * dy);
323 yield 0.1 * Math.max(0, 0.8 - d);
324 }
325 case "spheres" -> {
326 double scale = 2.0;
327 double r = 0.8;
328 double cx = scale * (Math.floor(x / scale) + 0.5);
329 double cy = scale * (Math.floor(y / scale) + 0.5);
330 double dx = x - cx;
331 double dy = y - cy;
332 double d2 = dx * dx + dy * dy;
333 if (d2 <= r * r) {
334 yield 0.03 * Math.sqrt(r * r - d2);
335 }
336 yield 0.0;
337 }
338 default -> throw new RuntimeException("not supported: " + name);
339 };
340 });
341 }
342
343 public static BufferedImage computeNormals(HeightFunction fun) {
344 BufferedImage out = new BufferedImage(1000, 1000, BufferedImage.TYPE_INT_RGB);
345 int[] arr = ((DataBufferInt) out.getRaster().getDataBuffer()).getData();
346 int sx = out.getWidth();
347 int sy = out.getHeight();
348
349 double delta = 0.00001;
350 double dxx = 1.0 / sx;
351 double dyy = 1.0 / sy;
352 for (int yy = 0; yy < sy; yy++) {
353 int nStart = sy * yy;
354 for (int xx = 0; xx < sx; xx++) {
355 double x = xx * dxx;
356 double y = yy * dyy;
357
358 // Compute the partial derivatives in x and y direction;
359 double fdx = fun.call(x + delta, y) - fun.call(x - delta, y);
360 double fdy = fun.call(x, y + delta) - fun.call(x, y - delta);
361 // We can compute the normal from the cross product of:
362 //
363 // df/dx x df/dy = [2*delta, 0, fdx] x [0, 2*delta, fdy]
364 // = [0*fdy - fdx*2*delta, fdx*0 - 2*delta*fdy, 2*delta*2*delta - 0*0]
365 double nx = -fdx * 2 * delta;
366 double ny = -2 * delta * fdy;
367 double nz = 2 * delta * 2 * delta;
368
369 // normalize
370 float dist = (float)Math.sqrt(nx * nx + ny * ny + nz * nz);
371 nx /= dist;
372 ny /= dist;
373 nz /= dist;
374
375 // Now transform [-1..1] -> [0..255]
376 int r = (int)(nx * 127f + 127f) & 0xff;
377 int g = (int)(ny * 127f + 127f) & 0xff;
378 int b = (int)(nz * 127f + 127f) & 0xff;
379 int c = (r << 16) + (g << 8) + b;
380 arr[nStart + xx] = c;
381 }
382 }
383 return out;
384 }
385
386 public void update() {
387 long nowTime = System.nanoTime();
388 float newFPS = 1e9f / (nowTime - lastTime);
389 fps = 0.99f * fps + 0.01f * newFPS;
390 lastTime = nowTime;
391
392 for (Light light : lights) {
393 light.update();
394 }
395
396 // Reset the buffer
397 int[] outputArray = ((DataBufferInt) output.getRaster().getDataBuffer()).getData();
398 Arrays.fill(outputArray, 0);
399
400 // Add in the contribution of each light
401 for (Light l : lights) {
402 computeLight(l);
403 }
404 computeLuminosityCorrection();
405
406 // Swap the buffers for double buffering.
407 var outputTmp = output;
408 output = output_2;
409 output_2 = outputTmp;
410
411 var outputRGBTmp = outputRGB;
412 outputRGB = outputRGB_2;
413 outputRGB_2 = outputRGBTmp;
414 }
415
416 public void computeLight(Light l) {
417 for (int i = 0; i < outputRGB.length; i++) {
418 float x = coordsX[i];
419 float y = coordsY[i];
420 float nx = normalsX[i];
421 float ny = normalsY[i];
422 float nz = normalsZ[i];
423
424 // Compute distance vector between the light and the pixel
425 float dx = x - l.x;
426 float dy = y - l.y;
427 float dz = 0.2f; // how much the lights float above the scene
428
429 // Compute the distance (dot product of d with itself)
430 float d2 = dx * dx + dy * dy + dz * dz;
431 float d = (float)Math.sqrt(d2);
432 float d3 = d * d2;
433
434 // Compute dot-product between distance and normal vector
435 float dotProduct = nx * dx + ny * dy + nz * dz;
436
437 // If the dot-product is negative:
438 // Light on wrong side -> 0
439 // If the dot-product is positive:
440 // There should be light normalize by distance (d), and divide by the
441 // squared distance (d2) to have physically accurately decaying light.
442 // Correct the luminosity so the RGB values are going to be close
443 // to 255, but not over.
444 float luminosity = Math.max(0, dotProduct / d3) * luminosityCorrection;
445
446 // Now we compute the color values that hopefully end up in the range
447 // [0..255]. If the hack/trick with luminosityCorrection fails, we may
448 // occasionally go out of the range and generate an overflow in the masking.
449 // This can lead to some funky visual artifacts around the lights, but it
450 // is quite rare.
451 //
452 // Feel free to play with the targetExposure below, and see if you can
453 // observe the artefacts.
454 int r = (int)(luminosity * l.r) & 0xff;
455 int g = (int)(luminosity * l.g) & 0xff;
456 int b = (int)(luminosity * l.b) & 0xff;
457 int c = (r << 16) + (g << 8) + b;
458 outputRGB[i] += c;
459 }
460 }
461
462 // This is a bit of a horrible hack, but it mostly works.
463 // Essentially, it tries to solve the "exposure" problem:
464 // It is hard to know how much light a pixel will receive at most, and
465 // we have to convert this value to a byte [0..255] at some point.
466 // If we chose the "exposure" too low, we get a very dark picture
467 // that is not very exciting to look at. If we over-expose, then we
468 // may overflow/clip the range [0..255], leading to unpleasant visual
469 // artifacts.
470 public void computeLuminosityCorrection() {
471 // Find maximum R, G, and B value.
472 float maxR = 0;
473 float maxG = 0;
474 float maxB = 0;
475 for (int i = 0; i < outputRGB.length; i++) {
476 int c = outputRGB[i];
477 int cr = (c >> 16) & 0xff;
478 int cg = (c >> 8) & 0xff;
479 int cb = (c >> 0) & 0xff;
480
481 maxR = Math.max(maxR, cr);
482 maxG = Math.max(maxG, cg);
483 maxB = Math.max(maxB, cb);
484 }
485
486 float maxC = Math.max(Math.max(maxR, maxG), maxB);
487
488 // Correct the maximum value to be 230, so we are safely in range 0..255
489 // Setting it instead to 255 will make the image brighter, but most likely
490 // it will give you some funky artefacts.
491 // Setting it to 100 will make the image darker.
492 float targetExposure = 230f;
493 luminosityCorrection *= targetExposure / maxC;
494 }
495 }
496
497 public static class MyDrawingPanel extends JPanel {
498 private final State state;
499
500 public MyDrawingPanel(State state) {
501 this.state = state;
502 }
503
504 @Override
505 protected void paintComponent(Graphics g) {
506 super.paintComponent(g);
507 Graphics2D g2d = (Graphics2D) g;
508
509 // Draw color output
510 g2d.drawImage(state.output_2, 0, 0, null);
511
512 // Draw position of lights
513 for (Light l : state.lights) {
514 g2d.setColor(new Color(l.r, l.g, l.b));
515 g2d.fillRect((int)(1000f * l.x) - 3, (int)(1000f * l.y) - 3, 6, 6);
516 }
517
518 g2d.setColor(new Color(0, 0, 0));
519 g2d.fillRect(0, 0, 150, 35);
520 g2d.setColor(new Color(255, 255, 255));
521 g2d.setFont(new Font("Consolas", Font.PLAIN, 30));
522 g2d.drawString("FPS: " + (int)Math.floor(state.fps), 0, 30);
523
524 g2d.drawImage(state.normals, 1000, 0, null);
525 }
526 }
527 }