From 030bd706da6fe945bf0fb3258583675b9286be11 Mon Sep 17 00:00:00 2001 From: Maik Marschner Date: Thu, 24 Oct 2024 21:43:33 +0200 Subject: [PATCH] Improve color serialization in scene files, remove black sky mode, and drop redundant camera preset name. (#1784) * Update inconsistent sky gradient color serialization. * Remove redundant black sky mode. * Drop the redundant camera preset name from the scene json file. * Use consistent color serialization and parsing for fog, sky and sun. * Use consistent color format for beacon beam color too. * Allow changing the color serialization format to hex. * Serialize custom water color with JsonUtil. * Bump the scene description file version. --- .../se/llbit/chunky/renderer/scene/Fog.java | 17 ++---- .../se/llbit/chunky/renderer/scene/Scene.java | 25 ++++---- .../llbit/chunky/renderer/scene/sky/Sky.java | 51 ++++++++--------- .../llbit/chunky/renderer/scene/sky/Sun.java | 27 +++------ .../llbit/chunky/ui/render/tabs/SkyTab.java | 3 - .../world/material/BeaconBeamMaterial.java | 20 +++++-- chunky/src/java/se/llbit/math/ColorUtil.java | 33 ++++++++++- chunky/src/java/se/llbit/util/JsonUtil.java | 57 +++++++++++++++++-- .../chunky/renderer/BlankRenderTest.java | 13 ----- .../src/test/se/llbit/math/ColorUtilTest.java | 42 ++++++++++++++ 10 files changed, 189 insertions(+), 99 deletions(-) create mode 100644 chunky/src/test/se/llbit/math/ColorUtilTest.java diff --git a/chunky/src/java/se/llbit/chunky/renderer/scene/Fog.java b/chunky/src/java/se/llbit/chunky/renderer/scene/Fog.java index 76f16794e3..87c69ad06b 100644 --- a/chunky/src/java/se/llbit/chunky/renderer/scene/Fog.java +++ b/chunky/src/java/se/llbit/chunky/renderer/scene/Fog.java @@ -13,6 +13,7 @@ import se.llbit.math.Vector3; import se.llbit.math.Vector4; import se.llbit.util.JsonSerializable; +import se.llbit.util.JsonUtil; public final class Fog implements JsonSerializable { private final Scene scene; @@ -213,11 +214,7 @@ private double sampleLayeredScatterOffset(Random random, double y1, double y2, d jsonLayers.add(jsonLayer); } fogObj.add("layers", jsonLayers); - JsonObject colorObj = new JsonObject(); - colorObj.add("red", fogColor.x); - colorObj.add("green", fogColor.y); - colorObj.add("blue", fogColor.z); - fogObj.add("color", colorObj); + fogObj.add("color", JsonUtil.rgbToJson(fogColor)); fogObj.add("fastFog", fastFog); return fogObj; } @@ -231,10 +228,7 @@ public void importFromJson(JsonObject json, Scene scene) { o.get("breadth").doubleValue(0), o.get("density").doubleValue(0), scene)).collect(Collectors.toCollection(ArrayList::new)); - JsonObject colorObj = json.get("color").object(); - fogColor.x = colorObj.get("red").doubleValue(fogColor.x); - fogColor.y = colorObj.get("green").doubleValue(fogColor.y); - fogColor.z = colorObj.get("blue").doubleValue(fogColor.z); + JsonUtil.rgbFromJson(json.get("color"), fogColor); fastFog = json.get("fastFog").boolValue(fastFog); } @@ -243,10 +237,7 @@ public void importFromLegacy(JsonObject json) { uniformDensity = json.get("fogDensity").doubleValue(uniformDensity); skyFogDensity = json.get("skyFogDensity").doubleValue(skyFogDensity); layers = new ArrayList<>(0); - JsonObject colorObj = json.get("fogColor").object(); - fogColor.x = colorObj.get("red").doubleValue(fogColor.x); - fogColor.y = colorObj.get("green").doubleValue(fogColor.y); - fogColor.z = colorObj.get("blue").doubleValue(fogColor.z); + JsonUtil.rgbFromJson(json.get("fogColor"), fogColor); fastFog = json.get("fastFog").boolValue(fastFog); } diff --git a/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java b/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java index 8d3690b5a3..7b0c578618 100644 --- a/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java +++ b/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java @@ -89,7 +89,7 @@ public class Scene implements JsonSerializable, Refreshable { public static final String EXTENSION = ".json"; /** The current Scene Description Format (SDF) version. */ - public static final int SDF_VERSION = 9; + public static final int SDF_VERSION = 10; protected static final double fSubSurface = 0.3; @@ -2568,11 +2568,7 @@ public void setUseCustomWaterColor(boolean value) { json.add("waterVisibility", waterVisibility); json.add("useCustomWaterColor", useCustomWaterColor); if (useCustomWaterColor) { - JsonObject colorObj = new JsonObject(); - colorObj.add("red", waterColor.x); - colorObj.add("green", waterColor.y); - colorObj.add("blue", waterColor.z); - json.add("waterColor", colorObj); + json.add("waterColor", JsonUtil.rgbToJson(waterColor)); } currentWaterShader.save(json); json.add("fog", fog.toJson()); @@ -2595,7 +2591,11 @@ public void setUseCustomWaterColor(boolean value) { json.add("camera", camera.toJson()); json.add("sun", sun.toJson()); json.add("sky", sky.toJson()); - json.add("cameraPresets", cameraPresets.copy()); + JsonObject cameraPresets = this.cameraPresets.copy(); + for (JsonMember item : cameraPresets.members) { + item.value.object().remove("name"); + } + json.add("cameraPresets", cameraPresets); JsonArray chunkList = new JsonArray(); for (ChunkPosition pos : chunks) { JsonArray chunk = new JsonArray(); @@ -2873,10 +2873,7 @@ else if(waterShader.equals("SIMPLEX")) waterVisibility = json.get("waterVisibility").doubleValue(waterVisibility); useCustomWaterColor = json.get("useCustomWaterColor").boolValue(useCustomWaterColor); if (useCustomWaterColor) { - JsonObject colorObj = json.get("waterColor").object(); - waterColor.x = colorObj.get("red").doubleValue(waterColor.x); - waterColor.y = colorObj.get("green").doubleValue(waterColor.y); - waterColor.z = colorObj.get("blue").doubleValue(waterColor.z); + JsonUtil.rgbFromJson(json.get("waterColor"), waterColor); } biomeColors = json.get("biomeColorsEnabled").boolValue(biomeColors); transparentSky = json.get("transparentSky").boolValue(transparentSky); @@ -2923,7 +2920,11 @@ else if(waterShader.equals("SIMPLEX")) } if (json.get("cameraPresets").isObject()) { - cameraPresets = json.get("cameraPresets").object(); + JsonObject cameraPresets = json.get("cameraPresets").object(); + for (JsonMember member : cameraPresets.members) { + member.value.object().set("name", new JsonString(member.name)); + } + this.cameraPresets = cameraPresets; } // Current SPP and render time are read after loading diff --git a/chunky/src/java/se/llbit/chunky/renderer/scene/sky/Sky.java b/chunky/src/java/se/llbit/chunky/renderer/scene/sky/Sky.java index a7e20a4f5b..8a7cb15b11 100644 --- a/chunky/src/java/se/llbit/chunky/renderer/scene/sky/Sky.java +++ b/chunky/src/java/se/llbit/chunky/renderer/scene/sky/Sky.java @@ -116,10 +116,7 @@ public enum SkyMode { SKYMAP_ANGULAR("Skymap (angular)"), /** Skybox. */ - SKYBOX("Skybox"), - - /** A completely black sky, useful for rendering an emitter-only pass. */ - BLACK("Black"); + SKYBOX("Skybox"); private String name; @@ -353,10 +350,6 @@ public void getSkyDiffuseColorInner(Ray ray) { } break; } - case BLACK: { - ray.color.set(0, 0, 0, 1); - break; - } } } @@ -641,11 +634,7 @@ public void setSkyCacheResolution(int resolution) { // Always save gradient. sky.add("gradient", gradientJson(gradient)); - JsonObject colorObj = new JsonObject(); - colorObj.add("red", color.x); - colorObj.add("green", color.y); - colorObj.add("blue", color.z); - sky.add("color", colorObj); + sky.add("color", JsonUtil.rgbToJson(color)); switch (mode) { case SKYMAP_EQUIRECTANGULAR: @@ -688,12 +677,17 @@ public void importFromJson(JsonObject json) { skyExposure = json.get("skyExposure").doubleValue(skyExposure); skyLightModifier = json.get("skyLight").doubleValue(skyLightModifier); apparentSkyLightModifier = json.get("apparentSkyLight").doubleValue(apparentSkyLightModifier); - if (!(json.get("mode").stringValue(mode.name()).equals("SKYMAP_PANORAMIC") || json.get("mode").stringValue(mode.name()).equals("SKYMAP_SPHERICAL"))) { - mode = SkyMode.get(json.get("mode").stringValue(mode.name())); + if (json.get("mode").stringValue(mode.name()).equals("BLACK")) { + mode = SkyMode.SOLID_COLOR; + color.x = 0; + color.y = 0; + color.z = 0; } else if (json.get("mode").stringValue(mode.name()).equals("SKYMAP_PANORAMIC")) { mode = SkyMode.SKYMAP_EQUIRECTANGULAR; } else if (json.get("mode").stringValue(mode.name()).equals("SKYMAP_SPHERICAL")) { mode = SkyMode.SKYMAP_ANGULAR; + } else { + mode = SkyMode.get(json.get("mode").stringValue(mode.name())); } horizonOffset = json.get("horizonOffset").doubleValue(horizonOffset); cloudsEnabled = json.get("cloudsEnabled").boolValue(cloudsEnabled); @@ -709,15 +703,7 @@ public void importFromJson(JsonObject json) { } } - if (json.get("color").isObject()) { - JsonObject colorObj = json.get("color").object(); - color.x = colorObj.get("red").doubleValue(1); - color.y = colorObj.get("green").doubleValue(1); - color.z = colorObj.get("blue").doubleValue(1); - } else { - // Maintain backwards-compatibility with scenes saved in older Chunky versions - color.set(JsonUtil.vec3FromJsonArray(json.get("color"))); - } + JsonUtil.rgbFromJson(json.get("color"), color); switch (mode) { case SKYMAP_EQUIRECTANGULAR: @@ -804,7 +790,11 @@ public static JsonArray gradientJson(Collection gradient) { JsonArray array = new JsonArray(); for (Vector4 stop : gradient) { JsonObject obj = new JsonObject(); - obj.add("rgb", ColorUtil.toString(stop.x, stop.y, stop.z)); + JsonObject colorObj = new JsonObject(); + colorObj.add("red", stop.x); + colorObj.add("green", stop.y); + colorObj.add("blue", stop.z); + obj.add("color", JsonUtil.rgbToJson(stop.toVec3())); obj.add("pos", stop.w); array.add(obj); } @@ -820,13 +810,18 @@ public static List gradientFromJson(JsonArray array) { JsonObject obj = array.get(i).object(); Vector3 color = new Vector3(); try { - ColorUtil.fromString(obj.get("rgb").stringValue(""), 16, color); + if (obj.get("color").isUnknown()) { + // support for old scene files (2.5.0 snapshot phase) + ColorUtil.fromString(obj.get("rgb").stringValue(""), 16, color); + } else { + JsonUtil.rgbFromJson(obj.get("color"), color); + } Vector4 stop = - new Vector4(color.x, color.y, color.z, obj.get("pos").doubleValue(Double.NaN)); + new Vector4(color.x, color.y, color.z, obj.get("pos").doubleValue(Double.NaN)); if (!Double.isNaN(stop.w)) { gradient.add(stop); } - } catch (NumberFormatException e) { + } catch (IllegalArgumentException e) { // Ignored. } } diff --git a/chunky/src/java/se/llbit/chunky/renderer/scene/sky/Sun.java b/chunky/src/java/se/llbit/chunky/renderer/scene/sky/Sun.java index 9458b57eb7..a91617db74 100644 --- a/chunky/src/java/se/llbit/chunky/renderer/scene/sky/Sun.java +++ b/chunky/src/java/se/llbit/chunky/renderer/scene/sky/Sun.java @@ -29,6 +29,7 @@ import se.llbit.math.Ray; import se.llbit.math.Vector3; import se.llbit.util.JsonSerializable; +import se.llbit.util.JsonUtil; /** * Sun model for ray tracing. @@ -501,16 +502,8 @@ public void getRandomSunDirection(Ray reflected, Random random) { sun.add("apparentBrightness", apparentBrightness); sun.add("radius", radius); sun.add("modifySunTexture", enableTextureModification); - JsonObject colorObj = new JsonObject(); - colorObj.add("red", color.x); - colorObj.add("green", color.y); - colorObj.add("blue", color.z); - sun.add("color", colorObj); - JsonObject apparentColorObj = new JsonObject(); - apparentColorObj.add("red", apparentColor.x); - apparentColorObj.add("green", apparentColor.y); - apparentColorObj.add("blue", apparentColor.z); - sun.add("apparentColor", apparentColorObj); + sun.add("color", JsonUtil.rgbToJson(color)); + sun.add("apparentColor", JsonUtil.rgbToJson(apparentColor)); JsonObject importanceSamplingObj = new JsonObject(); importanceSamplingObj.add("chance", importanceSampleChance); importanceSamplingObj.add("radius", importanceSampleRadius); @@ -528,18 +521,12 @@ public void importFromJson(JsonObject json) { radius = json.get("radius").doubleValue(radius); enableTextureModification = json.get("modifySunTexture").boolValue(enableTextureModification); - if (json.get("color").isObject()) { - JsonObject colorObj = json.get("color").object(); - color.x = colorObj.get("red").doubleValue(1); - color.y = colorObj.get("green").doubleValue(1); - color.z = colorObj.get("blue").doubleValue(1); + if (!json.get("color").isUnknown()) { + JsonUtil.rgbFromJson(json.get("color"), color); } - if (json.get("apparentColor").isObject()) { - JsonObject apparentColorObj = json.get("apparentColor").object(); - apparentColor.x = apparentColorObj.get("red").doubleValue(1); - apparentColor.y = apparentColorObj.get("green").doubleValue(1); - apparentColor.z = apparentColorObj.get("blue").doubleValue(1); + if (!json.get("apparentColor").isUnknown()) { + JsonUtil.rgbFromJson(json.get("apparentColor"), apparentColor); } if(json.get("importanceSampling").isObject()) { diff --git a/chunky/src/java/se/llbit/chunky/ui/render/tabs/SkyTab.java b/chunky/src/java/se/llbit/chunky/ui/render/tabs/SkyTab.java index 700ab4918a..ea604b6d70 100644 --- a/chunky/src/java/se/llbit/chunky/ui/render/tabs/SkyTab.java +++ b/chunky/src/java/se/llbit/chunky/ui/render/tabs/SkyTab.java @@ -189,9 +189,6 @@ public SimulatedSky fromString(String string) { case GRADIENT: skyModeSettings.getChildren().setAll(gradientEditor); break; - case BLACK: - skyModeSettings.getChildren().setAll(new Label("Selected mode has no settings.")); - break; case SKYBOX: skyModeSettings.getChildren().setAll(skyboxSettings); break; diff --git a/chunky/src/java/se/llbit/chunky/world/material/BeaconBeamMaterial.java b/chunky/src/java/se/llbit/chunky/world/material/BeaconBeamMaterial.java index cb74f4b3dd..e49341176e 100644 --- a/chunky/src/java/se/llbit/chunky/world/material/BeaconBeamMaterial.java +++ b/chunky/src/java/se/llbit/chunky/world/material/BeaconBeamMaterial.java @@ -2,15 +2,19 @@ import se.llbit.chunky.resources.Texture; import se.llbit.chunky.world.Material; +import se.llbit.json.JsonNumber; import se.llbit.json.JsonObject; +import se.llbit.json.JsonValue; import se.llbit.math.ColorUtil; import se.llbit.math.Ray; +import se.llbit.math.Vector3; +import se.llbit.util.JsonUtil; public class BeaconBeamMaterial extends Material { public static final int DEFAULT_COLOR = 0xF9FFFE; private int color; - private float[] beamColor = new float[4]; + private float[] beamColor = new float[3]; public BeaconBeamMaterial(int color) { super("beacon_beam", Texture.beaconBeam); @@ -20,7 +24,7 @@ public BeaconBeamMaterial(int color) { public void updateColor(int color) { this.color = color; - ColorUtil.getRGBAComponents(color, beamColor); + ColorUtil.getRGBComponents(color, beamColor); ColorUtil.toLinear(beamColor); } @@ -53,7 +57,13 @@ public float[] getColor(double u, double v) { @Override public void loadMaterialProperties(JsonObject json) { super.loadMaterialProperties(json); - updateColor(json.get("color").asInt(DEFAULT_COLOR)); + JsonValue color = json.get("color"); + if (color instanceof JsonNumber) { + // compatibility with older scene files + updateColor(color.asInt(DEFAULT_COLOR)); + } else { + updateColor(ColorUtil.getRGB(JsonUtil.rgbFromJson(color))); + } } public void saveMaterialProperties(JsonObject json) { @@ -62,6 +72,8 @@ public void saveMaterialProperties(JsonObject json) { json.add("emittance", this.emittance); json.add("roughness", this.roughness); json.add("metalness", this.metalness); - json.add("color", this.color); + Vector3 color = new Vector3(); + ColorUtil.getRGBComponents(this.color, color); + json.add("color", JsonUtil.rgbToJson(color)); } } \ No newline at end of file diff --git a/chunky/src/java/se/llbit/math/ColorUtil.java b/chunky/src/java/se/llbit/math/ColorUtil.java index 9d1d01ccd3..a022e25e39 100644 --- a/chunky/src/java/se/llbit/math/ColorUtil.java +++ b/chunky/src/java/se/llbit/math/ColorUtil.java @@ -132,6 +132,15 @@ public static void getRGBComponents(int irgb, Vector4 v) { v.z = (0xFF & irgb) / 255.f; } + /** + * Get the RGB color components from an INT RGB value. + */ + public static void getRGBComponents(int irgb, Vector3 v) { + v.x = (0xFF & (irgb >> 16)) / 255.f; + v.y = (0xFF & (irgb >> 8)) / 255.f; + v.z = (0xFF & irgb) / 255.f; + } + /** * Get the RGB color components from an INT RGB value. */ @@ -162,8 +171,10 @@ public static void getRGBAComponents(int irgb, Vector4 v) { } /** - * Get the RGBA color components from an INT ARGB value. + * Get the RGB color components from an INT RGB value. + * @deprecated Use {@link #getRGBComponents(int, Vector3)} instead, this method name is incorrect. */ + @Deprecated public static void getRGBAComponents(int irgb, Vector3 v) { v.x = (0xFF & (irgb >> 16)) / 255.f; v.y = (0xFF & (irgb >> 8)) / 255.f; @@ -346,7 +357,25 @@ public static java.awt.Color toAWT(Vector3 color) { public static void fromString(String text, int radix, Vector3 color) throws NumberFormatException { int rgb = Integer.parseInt(text, radix); - ColorUtil.getRGBAComponents(rgb, color); + ColorUtil.getRGBComponents(rgb, color); + } + + public static void fromHexString(String hex, Vector3 color) { + if (hex.startsWith("#")) { + hex = hex.substring(1); + } + + if (hex.length() == 3) { + hex = "" + hex.charAt(0) + hex.charAt(0) + + hex.charAt(1) + hex.charAt(1) + + hex.charAt(2) + hex.charAt(2); + } + + if (hex.length() != 6) { + throw new IllegalArgumentException("Expected three or six digit hex color"); + } + + fromString(hex, 16, color); } public static javafx.scene.paint.Color toFx(Vector3 color) { diff --git a/chunky/src/java/se/llbit/util/JsonUtil.java b/chunky/src/java/se/llbit/util/JsonUtil.java index a90431dc19..8c2e203df8 100644 --- a/chunky/src/java/se/llbit/util/JsonUtil.java +++ b/chunky/src/java/se/llbit/util/JsonUtil.java @@ -17,10 +17,8 @@ */ package se.llbit.util; -import se.llbit.json.Json; -import se.llbit.json.JsonArray; -import se.llbit.json.JsonObject; -import se.llbit.json.JsonValue; +import se.llbit.json.*; +import se.llbit.math.ColorUtil; import se.llbit.math.QuickMath; import se.llbit.math.Vector3; import se.llbit.nbt.Tag; @@ -64,4 +62,55 @@ public static JsonValue vec3ToJson(Vector3 vec) { array.add(Json.of(vec.z)); return array; } + + public static Vector3 rgbFromJson(JsonValue json) { + Vector3 color = new Vector3(); + rgbFromJson(json, color); + return color; + } + + /** + * Parse RGB color from JSON. + * + * @param json Either an object with red, green and blue keys (0…1), an array with 1…3 values (r,g,b) in range 0…1, or six digit hex color. + * @param color Target color vector (r,g,b from 0…1) + */ + public static void rgbFromJson(JsonValue json, Vector3 color) { + if (json.isObject()) { + color.set( + json.object().get("red").doubleValue(0), + json.object().get("green").doubleValue(0), + json.object().get("blue").doubleValue(0) + ); + } else if (json.isArray()) { + // Maintain backwards-compatibility with scenes saved in older Chunky versions (eg. for sky color) + color.set(vec3FromJsonArray(json)); + } else { + ColorUtil.fromHexString(json.stringValue("#000000"), color); + } + } + + /** + * Serialize an RGB color to JSON in a way that can be parsed by {@link #rgbFromJson(JsonValue)}. + *

+ * Depending on the chunky.colorSerializationFormat system property, this returns either an object with red, green and blue keys, or a hex string. + * + * @param color Color vector (r,g,b from 0…1) + * @return Serialized color + */ + public static JsonValue rgbToJson(Vector3 color) { + switch (System.getProperty("chunky.colorSerializationFormat", "object")) { + case "hex": { + return new JsonString(String.format("#%02x%02x%02x", (int) (color.x * 255), (int) (color.y * 255), (int) (color.z * 255))); + } + case "object": + default: { + JsonObject colorObj = new JsonObject(); + colorObj.add("red", color.x); + colorObj.add("green", color.y); + colorObj.add("blue", color.z); + return colorObj; + } + } + } } diff --git a/chunky/src/test/se/llbit/chunky/renderer/BlankRenderTest.java b/chunky/src/test/se/llbit/chunky/renderer/BlankRenderTest.java index 46c692c2ad..a1c545f0fe 100644 --- a/chunky/src/test/se/llbit/chunky/renderer/BlankRenderTest.java +++ b/chunky/src/test/se/llbit/chunky/renderer/BlankRenderTest.java @@ -94,19 +94,6 @@ private static void compareSamples(double[] expected, double[] actual, int size, } } - /** - * Render with a fully black sky. - */ - @Test public void testBlackSky() throws InterruptedException { - final Scene scene = new Scene(); - scene.setCanvasSize(WIDTH, HEIGHT); - scene.setRenderMode(RenderMode.RENDERING); - scene.setTargetSpp(2); - scene.setName("foobar"); - scene.sky().setSkyMode(Sky.SkyMode.BLACK); - renderAndCheckSamples(scene, new double[] {0, 0, 0}); - } - /** * Render with a solid sky color. */ diff --git a/chunky/src/test/se/llbit/math/ColorUtilTest.java b/chunky/src/test/se/llbit/math/ColorUtilTest.java new file mode 100644 index 0000000000..bc83c91c95 --- /dev/null +++ b/chunky/src/test/se/llbit/math/ColorUtilTest.java @@ -0,0 +1,42 @@ +package se.llbit.math; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ColorUtilTest { + @Test + void fromHexString() { + Vector3 color = new Vector3(); + ColorUtil.fromHexString("#c0ffee", color); + assertEquals(192 / 255., color.x, Ray.EPSILON); + assertEquals(255 / 255., color.y, Ray.EPSILON); + assertEquals(238 / 255., color.z, Ray.EPSILON); + + ColorUtil.fromHexString("#123", color); + assertEquals(17 / 255., color.x, Ray.EPSILON); + assertEquals(34 / 255., color.y, Ray.EPSILON); + assertEquals(51 / 255., color.z, Ray.EPSILON); + + ColorUtil.fromHexString("0ff1ce", color); + assertEquals(15 / 255., color.x, Ray.EPSILON); + assertEquals(241 / 255., color.y, Ray.EPSILON); + assertEquals(206 / 255., color.z, Ray.EPSILON); + + ColorUtil.fromHexString("456", color); + assertEquals(68 / 255., color.x, Ray.EPSILON); + assertEquals(85 / 255., color.y, Ray.EPSILON); + assertEquals(102 / 255., color.z, Ray.EPSILON); + + assertThrows(IllegalArgumentException.class, () -> { + ColorUtil.fromHexString("1234", color); + }); + assertThrows(IllegalArgumentException.class, () -> { + ColorUtil.fromHexString("#1234567", color); + }); + assertThrows(NumberFormatException.class, () -> { + ColorUtil.fromHexString("#badhex", color); + }); + } +} \ No newline at end of file