-1. SkyLibrary: the SkyControl runtime library and its automated tests
-2. SkyExamples: example applications
-3. SkyAssets: generate textures included in the library
+**SkySimulation** is the Take Some() sky and atmosphere package for Java / jMonkeyEngine worlds.
-Complete source code (in Java) is provided under
-[a 3-clause BSD license][license].
+We are **Take Some()** — a small engineering team building game-runtime infrastructure: simulation systems, rendering tools, world technology, and reusable engine modules for our own projects.
+This repository turns the original SkyControl-style sky runtime into a Take Some() package with practical controls for modern game worlds: atmospheric tuning, sun and moon texture customization, cloud lighting, stars, horizon haze, and release-ready Gradle publishing.
-
+## What we are building
-## Contents of this document
+We are building a reusable world-simulation layer for our games and engine experiments:
-+ [Important features](#features)
-+ [How to add SkyControl to an existing project](#add)
-+ [How to build SkyControl from source](#build)
-+ [Downloads](#downloads)
-+ [Conventions](#conventions)
-+ [External links](#links)
-+ [History](#history)
-+ [Acknowledgments](#acks)
+- dynamic sky, sun, moon, stars, haze, and cloud layers;
+- configurable atmospheric lighting profiles;
+- texture customization for both the sun and the moon;
+- lighting synchronization for ambient light, directional light, bloom, shadows, and viewport background color;
+- package distribution through Maven Local and GitHub Packages;
+- a foundation for richer worlds, weather, time-of-day systems, and future Take Some() engine modules.
+The public Java namespace currently remains compatible with SkyControl:
-
+```java
+import jme3utilities.sky.SkyControl;
+import jme3utilities.sky.SkyAtmosphere;
+```
-## Important features
+That keeps existing code usable while the artifact itself is published under Take Some() coordinates.
-+ sun, moon, stars, horizon haze, and up to 6 cloud layers
-+ compatible with static backgrounds such as cube maps
-+ high resolution textures are provided -- or customize with your own textures
-+ compatible with effects such as `SimpleWater`, shadows, bloom, and cartoon edges
-+ continuous and reversible motion and blending of cloud layers
-+ option to foreshorten clouds near the horizon
-+ continuous and reversible motion of sun, moon, and stars based on time of day
-+ updater to synchronize lighting and shadows with sun, moon, and clouds
-+ continuous scaling of sun, moon, and clouds
-+ option for continuously variable phase of the moon
-+ demonstration apps and online tutorial provided
-+ complete source code provided under FreeBSD license
-[Jump to the table of contents](#toc)
+## Source layout
+The public API stays in the stable `jme3utilities.sky` namespace:
-
+```text
+SkyControl
+SkyControlCore
+SkyMaterial / SkyMaterialCore
+SkyAtmosphere
+CloudLayer
+SunAndStars
+Updater
+```
-## How to add SkyControl to an existing project
+Implementation helpers are grouped by responsibility in focused packages:
-Adding SkyControl to an existing [jMonkeyEngine][jme] project should be
-a simple 6-step process:
+```text
+jme3utilities.sky.atmosphere atmospheric parsing and lighting math
+jme3utilities.sky.material texture sampling helpers
+jme3utilities.sky.scene scene-graph naming helpers
+jme3utilities.sky.update live updater application helpers
+```
- 1. Add SkyControl and its dependencies to the classpath.
- 2. Disable any existing sky which might interfere with SkyControl.
- 3. Add a `SkyControl` instance to some node in the scene graph.
- 4. Configure the `SkyControl` instance.
- 5. Enable the `SkyControl` instance.
- 6. Test and tune as necessary.
+Some helper classes remain package-private in `jme3utilities.sky` when moving
+those classes would force us to expose constructors or state that should stay
+encapsulated. Public compatibility is preferred over cosmetic directory moves.
-The SkyControl Library depends on
-the standard "jme3-effects" library from jMonkeyEngine and
-[the Heart Library][heart],
-which in turn depends on
-the standard "jme3-core" library.
+## Cloud weather and generated sky materials
-For projects built using [Maven] or [Gradle], it is sufficient to add a
-dependency on the SkyControl Library.
-The build tool should automatically resolve the remaining dependencies.
+Version `1.4.3` extracts weather subscription dispatch from the environment runtime, reduces per-event allocation during weather notifications, and keeps cloud/weather runtime state observable for gameplay systems.
-### Gradle-built projects
+- `SkyCloudPreset` provides `CLEAR`, `FAIR`, `OVERCAST`, `WISPY`, `CLOUDY`, `RAIN`, `STORM`, and `NIMBUS`.
+- `SkyControl.setCloudPreset(preset, seconds)` changes weather by fading current layers out, swapping alpha/normal/scale/motion while invisible, and fading target layers in.
+- DDS resources live under `SkyLibrary/src/main/resources/Textures/skies/clouds/presets`.
+- `cloud-weather-presets.json` records managed presets, raw bundles, DDS inventory, SHA-256 values, transition policy, and generated material/shader settings.
+- `:SkyAssets:skyMaterials` generates `dome02`, `dome06`, `dome20`, `dome22`, `dome60`, and `dome66` MatDefs and GLSL shaders.
+- BC5/ATI2 DDS normal maps are supported through the first-party fallback loader and are mapped to `Image.Format.RGTC2`.
-Add to the project’s "build.gradle" or "build.gradle.kts" file:
+Visual smoke test:
+
+```bat
+gradlew.bat :SkyExamples:SkyCloudWeatherSmoke
+```
- repositories {
- mavenCentral()
- }
- dependencies {
- implementation("com.github.stephengold:SkyControl:1.1.0")
- }
-For some older versions of Gradle,
-it's necessary to replace `implementation` with `compile`.
-
-### Maven-built projects
-
-Add to the project’s "pom.xml" file:
-
-
-
- mvnrepository
- https://repo1.maven.org/maven2/
-
-
-
-
- com.github.stephengold
- SkyControl
- 1.1.0
-
-
-### Ant-built projects
+## Package
-For projects built using [Ant], download the SkyControl and [Heart]
-libraries from GitHub:
+### Release package
+
+```kotlin
+repositories {
+ mavenCentral()
+ maven {
+ url = uri("https://maven.pkg.github.com/Take-Some/SkySimulation")
+ credentials {
+ username = findProperty("gpr.user") as String? ?: System.getenv("GITHUB_ACTOR")
+ password = findProperty("gpr.key") as String? ?: System.getenv("GITHUB_TOKEN")
+ }
+ }
+}
-+ https://github.com/stephengold/SkyControl/releases/tag/latest
-+ https://github.com/stephengold/Heart/releases/tag/8.3.2
+dependencies {
+ implementation("dev.takesome:sky-simulation:1.4.3")
+}
+```
-You'll definitely want both class jars
-and probably the "-sources" and "-javadoc" jars as well.
+### Local development package
-Open the project's properties in the IDE (JME SDK or NetBeans):
+First install the package into Maven Local:
-1. Right-click on the project (not its assets) in the "Projects" window.
-2. Select "Properties" to open the "Project Properties" dialog.
-3. Under "Categories:" select "Libraries".
-4. Click on the "Compile" tab.
-5. Add the [Heart] class jar:
- + Click on the "Add JAR/Folder" button.
- + Navigate to the download folder.
- + Select the "Heart-8.3.2.jar" file.
- + Click on the "Open" button.
-6. (optional) Add jars for javadoc and sources:
- + Click on the "Edit" button.
- + Click on the "Browse..." button to the right of "Javadoc:"
- + Select the "Heart-8.3.2-javadoc.jar" file.
- + Click on the "Open" button.
- + Click on the "Browse..." button to the right of "Sources:"
- + Select the "Heart-8.3.2-sources.jar" file.
- + Click on the "Open" button again.
- + Click on the "OK" button to close the "Edit Jar Reference" dialog.
-7. Similarly, add the SkyControl jar(s).
-8. Click on the "OK" button to exit the "Project Properties" dialog.
+```bat
+gradlew.bat packageLocal
+```
-[Jump to the table of contents](#toc)
+Then use it from another Gradle project:
+```kotlin
+repositories {
+ mavenLocal()
+ mavenCentral()
+}
-
+dependencies {
+ implementation("dev.takesome:sky-simulation:1.4.3-SNAPSHOT")
+}
+```
-## How to build SkyControl from source
+## Build
-1. Install a [Java Development Kit (JDK)][adoptium],
- version 17 or higher,
- if you don't already have one.
-2. Point the `JAVA_HOME` environment variable to your JDK installation:
- (In other words, set it to the path of a directory/folder
- containing a "bin" that contains a Java executable.
- That path might look something like
- "C:\Program Files\Eclipse Adoptium\jdk-17.0.3.7-hotspot"
- or "/usr/lib/jvm/java-17-openjdk-amd64/" or
- "/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home" .)
- + using Bash or Zsh: `export JAVA_HOME="` *path to installation* `"`
- + using [Fish]: `set -g JAVA_HOME "` *path to installation* `"`
- + using Windows Command Prompt: `set JAVA_HOME="` *path to installation* `"`
- + using PowerShell: `$env:JAVA_HOME = '` *path to installation* `'`
-3. Download and extract the SkyControl source code from GitHub:
- + using [Git]:
- + `git clone https://github.com/stephengold/SkyControl.git`
- + `cd SkyControl`
- + `git checkout -b latest 1.1.0`
- + using a web browser:
- + browse to [the latest release][latest]
- + follow the "Source code (zip)" link
- + save the ZIP file
- + extract the contents of the saved ZIP file
- + `cd` to the extracted directory/folder
-4. Run the [Gradle] wrapper:
- + using Bash or Fish or PowerShell or Zsh: `./gradlew build`
- + using Windows Command Prompt: `.\gradlew build`
+Requirements:
-After a successful build,
-Maven artifacts will be found in "SkyLibrary/build/libs".
+- JDK 17+
+- Gradle wrapper from this repository
-You can install the artifacts to your local Maven repository:
-+ using Bash or Fish or PowerShell or Zsh: `./gradlew install`
-+ using Windows Command Prompt: `.\gradlew install`
+Common commands:
-You can restore the project to a pristine state:
-+ using Bash or Fish or PowerShell or Zsh: `./gradlew clean`
-+ using Windows Command Prompt: `.\gradlew clean`
+```bat
+gradlew.bat :SkyLibrary:compileJava
+gradlew.bat :SkyLibrary:checkstyleMain
+gradlew.bat packageLocal
+```
-[Jump to the table of contents](#toc)
+Build release artifacts locally:
+```bat
+gradlew.bat :SkyLibrary:assemble -PskySimulationVersion=1.4.3
+```
-
+Artifacts are generated in:
-## Downloads
+```text
+SkyLibrary/build/libs
+```
-Newer releases (since v0.9.27) can be downloaded from
-[GitHub](https://github.com/stephengold/SkyControl/releases).
+## Release
-Older releases (v0.9.0 through v0.9.26) can be downloaded from
-[the Jme3-utilities Project](https://github.com/stephengold/jme3-utilities/releases).
+A GitHub release is produced by pushing a version tag:
-Newer Maven artifacts (since v0.9.30) are available from
-[MavenCentral](https://central.sonatype.com/artifact/com.github.stephengold/SkyControl/1.1.0/versions).
+```bat
+git tag -a v1.4.3 -m "SkySimulation v1.4.3"
+git push origin v1.4.3
+```
-Old Maven artifacts (v0.9.25 through v0.9.29) are available from JCenter.
+The release workflow publishes the Maven package to GitHub Packages and attaches the JAR, sources, Javadoc, POM, and Gradle module metadata to the GitHub Release.
-[Jump to the table of contents](#toc)
+## Modules
+- `SkyLibrary` — runtime library and public API.
+- `SkyAssets` — generated sky, star, sun, moon, cloud, and haze textures.
+- `SkyExamples` — example applications and test scenes.
-
+## Current Take Some() additions
-## Conventions
-
-Package names begin with `jme3utilities.sky`
-
-Both the source code and the pre-built libraries are compatible with JDK 8.
-
-In the default world coordinate system:
-
-+ the `+X` axis points toward the northern horizon
-+ the `+Y` axis points up (toward the zenith)
-+ the `+Z` axis points toward the eastern horizon
-
-However, these axis assignments can be overridden using `SunAndStars.setAxes()`.
-
-[Jump to the table of contents](#toc)
-
-
-
-
-## External links
-
-+ November 2013 [SkyControl demo video](https://www.youtube.com/watch?v=FsJRM6tr3oQ)
-+ January 2014 [SkyControl update video](https://www.youtube.com/watch?v=gE4wxgBIkaw)
-+ A [driving simulator](https://github.com/stephengold/jme-vehicles)
- that uses SkyControl.
-+ A [flight simulation game](https://github.com/ZoltanTheHun/SkyHussars)
- that uses SkyControl.
+- `SkyAtmosphere` profile for atmospheric lighting and realism tuning.
+- `earthlike-atmosphere.properties` default tuning preset.
+- Explicit `setSunTexture(...)` and `setMoonTexture(...)` APIs.
+- `clearMoonTexture()` for returning to phase-preset moon textures.
+- GitHub Packages publishing under `dev.takesome:sky-simulation`.
+- Release workflow for GitHub Releases.
+- `SkyCloudPreset` weather presets with gradual transitions.
+- DDS cloud preset resources plus `cloud-weather-presets.json` registry.
+- Generated sky MatDefs/GLSL via `:SkyAssets:skyMaterials`.
+- BC5/ATI2 DDS normal-map fallback loader for cloud normals.
+- Smooth atmosphere transitions for gradient style, sunset intensity, and halo intensity.
+- Game-facing weather subscriptions for exact weather ids, storm-like states, precipitation thresholds, wind thresholds, and all weather changes.
+- Expanded logging for Lua weather ABI loading, cloud preset transitions, generated sky material creation, and compressed DDS/BC cloud texture metadata.
-[Jump to the table of contents](#toc)
+## Attribution
+SkySimulation is based on the SkyControl project lineage and keeps the BSD-style license terms from the original codebase. Take Some() maintains this fork for our own engine/runtime work while preserving compatibility where it matters.
-
-
-## History
-
-The evolution of this project is chronicled in
-[its release log][log].
-
-SkyControl has its roots in SkyDome by Cris (aka "t0neg0d").
-
-From November 2013 to September 2015,
-SkyControl was part of the Jme3-utilities Project at
-[Google Code](https://code.google.com/archive/).
-
-From September 2015 to August 2020,
-SkyControl was part of the Jme3-utilities Project at
-[GitHub](https://github.com/stephengold/jme3-utilities).
-
-Since August 2020, SkyControl has been a separate project, hosted at
-[GitHub][skycontrol].
-
-Old (2014) versions of SkyControl can still be found in
-[the jMonkeyEngine-Contributions Project](https://github.com/jMonkeyEngine-Contributions/SkyControl).
-
-[Jump to the table of contents](#toc)
-
-
-
-
-## Acknowledgments
-
-Like most projects, the SkyControl Project builds on the work of many who
-have gone before. I therefore acknowledge the following
-artists and software developers:
-
-+ Cris (aka "t0neg0d") for creating SkyDome (which provided both an inspiration
- and a starting point for SkyControl) and also for encouraging me to run with
- it ... thank you yet again!
-+ Paul Speed, for helpful insights which got me unstuck during debugging
-+ Rémy Bouquet (aka "nehon") for many helpful insights
-+ Alexandr Brui (aka "javasabr") for a solving a problem with the
- de-serialization of `SkyControl`
-+ the brave souls who volunteered to be alpha testers for SkyControl, including:
- + Davis Rollman
- + "Lockhead"
- + Jonatan Dahl
- + Mindaugas (aka "eraslt")
- + Thomas Kluge
- + "pixelapp"
- + Roger (aka "stenb")
-+ the beta testers for SkyControl, including:
- + "madjack"
- + Benjamin D.
- + "Fissll"
- + Davis Rollman
-+ users who found and reported bugs in later versions:
- + Rami Manaf
- + Anton Starastsin (aka "Antonystar")
-+ the creators of (and contributors to) the following software:
- + Adobe Photoshop Elements
- + the Ant build tool
- + the [Blender] 3-D animation suite
- + the [Checkstyle] tool
- + the [FindBugs] source-code analyzer
- + the [Firefox] and [Chrome] web browsers
- + Gimp, the GNU Image Manipulation Program
- + the [Git] revision-control system and GitK commit viewer
- + the [GitKraken] client
- + the [Gradle] build tool
- + Guava core libraries for Java
- + the [IntelliJ IDEA][idea] and [NetBeans] integrated development environments
- + the [Java] compiler, standard doclet, and runtime environment
- + the JCommander Java framework
- + [jMonkeyEngine][jme] and the jME3 Software Development Kit
- + the [Linux Mint][mint] operating system
- + [LWJGL], the Lightweight Java Game Library
- + the [Markdown] document-conversion tool
- + the [Meld] visual merge tool
- + Microsoft Windows
- + the [Nifty] graphical user-interface library
- + [Open Broadcaster Software Studio][obs]
- + the PMD source-code analyzer
- + Alex Peterson's Spacescape tool
- + the Subversion revision-control systems
- + the [WinMerge] differencing and merging tool
-
-Many of SkyControl's assets were based on the works of others who licensed their
-works under liberal terms or contributed them to the public domain.
-For this I thank:
-
-+ Cris (aka "t0neg0d")
-+ Jacques Descloitres, MODIS Rapid Response Team, NASA/GSFC
-+ Tom Ruen
-
-I am grateful to [GitHub], [Sonatype], [JFrog], [YouTube], and [Imgur]
-for providing free hosting for this project
-and many other open-source projects.
-
-I'm also grateful to Quinn (for lending me one of her microphones) and finally
-my dear Holly, for keeping me sane.
-
-If I've misattributed anything or left anyone out, please let me know, so I can
-correct the situation: sgold@sonic.net
-
-[Jump to the table of contents](#toc)
-
-
-[adoptium]: https://adoptium.net/releases.html "Adoptium Project"
-[ant]: https://ant.apache.org "Apache Ant Project"
-[blender]: https://docs.blender.org "Blender Project"
-[bsd3]: https://opensource.org/licenses/BSD-3-Clause "3-Clause BSD License"
-[checkstyle]: https://checkstyle.org "Checkstyle"
-[chrome]: https://www.google.com/chrome "Chrome"
-[elements]: https://www.adobe.com/products/photoshop-elements.html "Photoshop Elements"
-[findbugs]: http://findbugs.sourceforge.net "FindBugs Project"
-[firefox]: https://www.mozilla.org/en-US/firefox "Firefox"
-[fish]: https://fishshell.com/ "Fish command-line shell"
-[git]: https://git-scm.com "Git"
-[github]: https://github.com "GitHub"
-[gitkraken]: https://www.gitkraken.com "GitKraken client"
-[gradle]: https://gradle.org "Gradle Project"
-[heart]: https://github.com/stephengold/Heart "Heart Project"
-[idea]: https://www.jetbrains.com/idea/ "IntelliJ IDEA"
-[imgur]: https://imgur.com/ "Imgur"
-[java]: https://en.wikipedia.org/wiki/Java_(programming_language) "Java programming language"
-[jfrog]: https://www.jfrog.com "JFrog"
-[jme]: https://jmonkeyengine.org "jMonkeyEngine Project"
-[latest]: https://github.com/stephengold/SkyControl/releases/latest "latest release"
-[license]: https://github.com/stephengold/SkyControl/blob/master/LICENSE "SkyControl license"
-[log]: https://github.com/stephengold/SkyControl/blob/master/SkyLibrary/release-notes.md "release log"
-[lwjgl]: https://www.lwjgl.org "Lightweight Java Game Library"
-[markdown]: https://daringfireball.net/projects/markdown "Markdown Project"
-[maven]: https://maven.apache.org "Maven Project"
-[meld]: https://meldmerge.org "Meld merge tool"
-[mint]: https://linuxmint.com "Linux Mint Project"
-[netbeans]: https://netbeans.org "NetBeans Project"
-[nifty]: http://nifty-gui.github.io/nifty-gui "Nifty GUI Project"
-[obs]: https://obsproject.com "Open Broadcaster Software Project"
-[skycontrol]: https://github.com/stephengold/SkyControl "SkyControl Project"
-[sonatype]: https://www.sonatype.com "Sonatype"
-[utilities]: https://github.com/stephengold/jme3-utilities "Jme3-utilities Project"
-[winmerge]: https://winmerge.org "WinMerge Project"
-[youtube]: https://www.youtube.com/ "YouTube"
+See `LICENSE` and source headers for license details.
diff --git a/RELEASE_NOTES_v1.1.0.md b/RELEASE_NOTES_v1.1.0.md
new file mode 100644
index 0000000..2d0f654
--- /dev/null
+++ b/RELEASE_NOTES_v1.1.0.md
@@ -0,0 +1,3 @@
+# SkySimulation v1.1.0
+
+Cloud weather, generated sky materials, DDS presets, and BC5 cloud normal-map support.
diff --git a/RELEASE_NOTES_v1.4.0.md b/RELEASE_NOTES_v1.4.0.md
new file mode 100644
index 0000000..3943b2a
--- /dev/null
+++ b/RELEASE_NOTES_v1.4.0.md
@@ -0,0 +1,59 @@
+# SkySimulation v1.4.0
+
+SkySimulation 1.4.0 turns the package into a Lua-driven sky runtime bridge for Take Some / Helix / NOESIS style engine integration.
+
+## Highlights
+
+- Added a game-facing runtime layer with environment snapshots, weather metrics, lighting snapshots, and world clock support.
+- Added Lua weather ABI support through `helix/lua/sky/weather.lua`.
+- Added typed data-driven weather presets through `SkyCloudPresetDefinition`, `SkyCloudPresetRegistry`, and `SkyCloudPresetLoader`.
+- Added Lua boot/config ABI support through `helix/lua/sky/default-sky.lua`.
+- Added typed `SkySimulationConfig` loading and application for atmosphere profile, initial weather, clock, stars mode, lower dome, cloud flattening, cloud Y offset, and integration defaults.
+- Added command ABI support through `helix/lua/sky/commands.lua` and `SkyCommandBus`.
+- Added commands: `sky.weather.set`, `sky.weather.list`, `sky.clock.setTime`, `sky.clock.advance`, `sky.environment.snapshot`, and `sky.config.reload`.
+- Added `weatherId()` to `SkyEnvironmentSnapshot` for stable external runtime snapshots.
+- Preserved the existing `jme3utilities.sky` public namespace and direct Java APIs while adding Lua ABI control paths.
+- Strengthened release validation: the release workflow now runs `clean build packageLocal` before publishing.
+
+## Runtime ABI resources
+
+```text
+helix/lua/sky/weather.lua
+helix/lua/sky/default-sky.lua
+helix/lua/sky/commands.lua
+```
+
+## Java entry points
+
+```java
+SkySimulationConfig config =
+ SkySimulationConfigLoader.loadDefault(assetManager);
+
+SkyControl skyControl = config.createControl(assetManager, camera);
+
+SkyCommandBus commandBus = new SkyCommandBus(assetManager, skyControl);
+commandBus.execute(SkyCommandIds.weatherSet, "STORM", "90");
+commandBus.execute(SkyCommandIds.clockSetTime, "18.5");
+SkyCommandResult snapshot =
+ commandBus.execute(SkyCommandIds.environmentSnapshot);
+```
+
+## Package coordinates
+
+```kotlin
+implementation("dev.takesome:sky-simulation:1.4.0")
+```
+
+For local development snapshots:
+
+```kotlin
+implementation("dev.takesome:sky-simulation:1.4.0-SNAPSHOT")
+```
+
+## Validation
+
+Release validation was run with:
+
+```bat
+gradlew.bat clean build packageLocal -PskySimulationVersion=1.4.0
+```
diff --git a/RELEASE_NOTES_v1.4.1.md b/RELEASE_NOTES_v1.4.1.md
new file mode 100644
index 0000000..2256d3c
--- /dev/null
+++ b/RELEASE_NOTES_v1.4.1.md
@@ -0,0 +1,48 @@
+# SkySimulation v1.4.1
+
+SkySimulation 1.4.1 adds runtime-controllable atmospheric gradient styling for Helix / NOESIS driven worlds.
+
+## Highlights
+
+- Add physically-inspired horizon gradient weighting for sunrise, sunset, and low-altitude moonlight.
+- Add `SkyGradientStyle` presets: `REALISTIC`, `CINEMATIC`, and `FANTASY`.
+- Add `SkyAtmosphere` runtime controls for `gradientStyle`, `sunsetIntensity`, `sunHaloIntensity`, and `moonHaloIntensity`.
+- Add Lua config ABI fields under `atmosphere` for gradient style and halo/sunset intensities.
+- Add Lua gradient presets in `helix/lua/sky/default-sky.lua`.
+- Add runtime atmosphere command ABI entries in `helix/lua/sky/commands.lua`:
+ - `sky.atmosphere.setGradient`
+ - `sky.atmosphere.setSunsetIntensity`
+ - `sky.atmosphere.setSunHaloIntensity`
+ - `sky.atmosphere.setMoonHaloIntensity`
+- Extend `SkyCommandBus` so NOESIS / Helix can change atmospheric style at runtime without rebuilding Java.
+- Improve GitHub CI for push, pull request, and manual validation on Java 17 Linux and Windows.
+- Harden release workflow by using `--no-daemon` for Gradle commands.
+
+## Runtime command example
+
+```lua
+sky.atmosphere.setGradient("FANTASY")
+sky.atmosphere.setSunsetIntensity(2.0)
+sky.atmosphere.setSunHaloIntensity(1.7)
+sky.atmosphere.setMoonHaloIntensity(1.8)
+```
+
+## Package coordinates
+
+```kotlin
+implementation("dev.takesome:sky-simulation:1.4.1")
+```
+
+For local development builds:
+
+```kotlin
+implementation("dev.takesome:sky-simulation:1.4.1-SNAPSHOT")
+```
+
+## Validation
+
+Validated with:
+
+```bat
+gradlew.bat --no-daemon clean build packageLocal --console=plain --stacktrace -PskySimulationVersion=1.4.1
+```
diff --git a/RELEASE_NOTES_v1.4.2.md b/RELEASE_NOTES_v1.4.2.md
new file mode 100644
index 0000000..383b118
--- /dev/null
+++ b/RELEASE_NOTES_v1.4.2.md
@@ -0,0 +1,32 @@
+# SkySimulation v1.4.2
+
+SkySimulation 1.4.2 improves runtime control and observability for atmosphere and weather systems.
+
+## Highlights
+
+- Added smooth runtime atmosphere transitions for gradient style, sunset intensity, sun halo intensity, and moon halo intensity.
+- Extended the atmosphere command ABI with optional `transitionSeconds` arguments for live cinematic changes.
+- Added typed game-facing weather subscriptions through `SkyEnvironmentRuntime`.
+- Added subscription filters for exact weather ids, built-in presets, storm-like states, precipitation thresholds, cloudiness thresholds, wind thresholds, and all weather changes.
+- Added cancellable `SkyWeatherSubscription` handles and immutable `SkyWeatherEvent` payloads.
+- Expanded logging for Lua weather ABI loading, preset parsing, weather state changes, cloud transition lifecycle, generated sky material creation, and cloud normal-map application.
+- Added compressed DDS/BC texture diagnostics for cloud normals: dimensions, mip levels, FourCC/code, jME image format, and block byte size.
+- Added tests for atmosphere transitions and weather subscriptions.
+
+## Coordinates
+
+```kotlin
+implementation("dev.takesome:sky-simulation:1.4.2")
+```
+
+Local development snapshot:
+
+```kotlin
+implementation("dev.takesome:sky-simulation:1.4.2-SNAPSHOT")
+```
+
+## Validation
+
+```bat
+gradlew.bat :SkyLibrary:test --no-daemon
+```
diff --git a/RELEASE_NOTES_v1.4.3.md b/RELEASE_NOTES_v1.4.3.md
new file mode 100644
index 0000000..935cb14
--- /dev/null
+++ b/RELEASE_NOTES_v1.4.3.md
@@ -0,0 +1,27 @@
+# SkySimulation v1.4.3
+
+SkySimulation 1.4.3 improves the runtime architecture around game-facing weather events and prepares the package for safer growth behind the protected `master` branch.
+
+## Highlights
+
+- Extracted weather subscription management from `SkyEnvironmentRuntime` into `SkyWeatherSubscriptionRegistry`.
+- Reduced weather-event dispatch overhead by avoiding per-event `ArrayList` snapshots and active-list `contains()` checks.
+- Preserved the existing typed weather subscription API, cancellable handles, listener isolation, and current-state replay behavior.
+- Kept runtime weather observability for gameplay, UI, AI, fog-of-war, and audio systems.
+- Added release documentation for the `1.4.3` package coordinates.
+
+## Coordinates
+
+```kotlin
+implementation("dev.takesome:sky-simulation:1.4.3")
+```
+
+Local development snapshot:
+
+```kotlin
+implementation("dev.takesome:sky-simulation:1.4.3-SNAPSHOT")
+```
+
+## Validation
+
+- `gradlew.bat :SkyLibrary:test --no-daemon --console=plain --stacktrace`
diff --git a/SkyAssets/build.gradle b/SkyAssets/build.gradle
index 18a0adf..51c0254 100644
--- a/SkyAssets/build.gradle
+++ b/SkyAssets/build.gradle
@@ -8,9 +8,12 @@ plugins {
alias(libs.plugins.download) // to retrieve files from URLs
}
+apply from: file('skyMaterials.gradle')
+
ext {
bsc = 'src/main/resources/bsc5.dat'
skies = '../SkyLibrary/src/main/resources/Textures/skies'
+ skyResources = '../SkyLibrary/src/main/resources'
}
sourceSets.main.java {
@@ -21,6 +24,7 @@ sourceSets.main.java {
dependencies {
implementation(libs.heart)
implementation(libs.jcommander)
+ implementation(libs.luaj.jse)
implementation(libs.jme3.core)
implementation(libs.jme3.effects)
@@ -52,6 +56,10 @@ tasks.register('cleanDownload', Delete) {
delete file("${bsc}.gz")
}
+// Register tasks to generate generated sky material definitions/shaders:
+
+processResources.dependsOn('skyMaterials')
+
// Register tasks to generate sky textures:
tasks.register('skyTextures') {
@@ -61,7 +69,13 @@ tasks.register('skyTextures') {
description = 'generate texture assets distributed with SkyControl'
}
tasks.register('cleanSkyTextures', Delete) {
- delete fileTree(dir: skies)
+ delete file("$skies/clouds/clear.png")
+ delete file("$skies/clouds/fbm.png")
+ delete file("$skies/clouds/overcast.png")
+ delete fileTree(dir: "$skies/moon-nonviral")
+ delete fileTree(dir: "$skies/ramps")
+ delete fileTree(dir: "$skies/star-maps")
+ delete fileTree(dir: "$skies/suns")
}
tasks.register('clouds', JavaExec) {
diff --git a/SkyAssets/skyMaterials.gradle b/SkyAssets/skyMaterials.gradle
new file mode 100644
index 0000000..0c6393d
--- /dev/null
+++ b/SkyAssets/skyMaterials.gradle
@@ -0,0 +1,306 @@
+// Gradle support for generated sky material definitions and shaders.
+
+ext.skyDomeProfiles = [
+ [name: 'dome02', objects: 0, clouds: 2],
+ [name: 'dome06', objects: 0, clouds: 6],
+ [name: 'dome20', objects: 2, clouds: 0],
+ [name: 'dome22', objects: 2, clouds: 2],
+ [name: 'dome60', objects: 6, clouds: 0],
+ [name: 'dome66', objects: 6, clouds: 6],
+]
+
+tasks.register('skyMaterials') {
+ description = 'generate sky material definitions and shaders'
+ outputs.dir("$skyResources/MatDefs/skies")
+ outputs.dir("$skyResources/Shaders/skies")
+
+ doLast {
+ def profiles = project.ext.skyDomeProfiles
+ profiles.each { profile ->
+ String name = profile.name
+ int numObjects = profile.objects
+ int numClouds = profile.clouds
+ writeGenerated("$skyResources/MatDefs/skies/$name/${name}.j3md",
+ makeMatDef(name, numObjects, numClouds))
+ writeGenerated("$skyResources/Shaders/skies/$name/${name}.vert",
+ makeVertexShader(name, numObjects, numClouds))
+ writeGenerated("$skyResources/Shaders/skies/$name/${name}.frag",
+ makeFragmentShader(name, numObjects, numClouds, false))
+ writeGenerated("$skyResources/Shaders/skies/$name/${name}glow.frag",
+ makeFragmentShader(name, numObjects, numClouds, true))
+ }
+ }
+}
+
+void writeGenerated(String path, String text) {
+ File target = file(path)
+ target.parentFile.mkdirs()
+ target.text = text.replace('\n', System.lineSeparator())
+}
+
+String makeMatDef(String name, int objects, int clouds) {
+ StringBuilder b = new StringBuilder()
+ b << generatedLine('//')
+ b << "// A generated material for SkyMaterial: $objects objects and "
+ b << "$clouds cloud layers.\n\n"
+ b << "MaterialDef $name {\n"
+ b << ' MaterialParameters {\n'
+ b << ' Color ClearColor\n'
+ b << ' Color ClearGlow\n'
+ b << ' Vector2 TopCoord\n\n'
+ b << ' Texture2D StarsColorMap\n'
+ for (int i = 0; i < objects; ++i) {
+ b << "\n Color Object${i}Color\n"
+ b << " Color Object${i}Glow\n"
+ b << "\tTexture2D Object${i}ColorMap\n"
+ b << "\tVector2 Object${i}Center\n"
+ b << "\tVector2 Object${i}TransformU\n"
+ b << "\tVector2 Object${i}TransformV\n"
+ }
+ for (int i = 0; i < clouds; ++i) {
+ b << "\n Color Clouds${i}Color\n"
+ b << " Color Clouds${i}Glow\n"
+ b << " Float Clouds${i}Scale : 1.0\n"
+ b << "\tTexture2D Clouds${i}AlphaMap\n"
+ b << "\tTexture2D Clouds${i}NormalMap\n"
+ b << "\tVector2 Clouds${i}Offset\n"
+ }
+ b << '\n\tColor HazeColor\n'
+ b << ' Color HazeGlow\n'
+ b << '\tTexture2D HazeAlphaMap\n'
+ b << ' }\n\n'
+ b << makeTechnique(name, objects, clouds, false)
+ b << '\n'
+ b << makeTechnique(name, objects, clouds, true)
+ b << '}\n'
+ return b.toString()
+}
+
+String makeTechnique(String name, int objects, int clouds, boolean glow) {
+ StringBuilder b = new StringBuilder()
+ b << (glow ? ' Technique Glow {\n' : ' Technique {\n')
+ b << ' Defines {\n'
+ if (!glow) {
+ b << '\t HAS_STARS : StarsColorMap\n'
+ }
+ for (int i = 0; i < objects; ++i) {
+ b << "\t HAS_OBJECT${i} : Object${i}ColorMap\n"
+ }
+ for (int i = 0; i < clouds; ++i) {
+ b << "\t HAS_CLOUDS${i} : Clouds${i}AlphaMap\n"
+ b << "\t HAS_CLOUDS${i}_NORMAL : Clouds${i}NormalMap\n"
+ }
+ b << '\t HAS_HAZE : HazeAlphaMap\n'
+ b << ' }\n'
+ String frag = glow ? "${name}glow.frag" : "${name}.frag"
+ b << " FragmentShader GLSL300 GLSL150 GLSL100: "
+ b << "Shaders/skies/${name}/${frag}\n"
+ b << " VertexShader GLSL300 GLSL150 GLSL100: "
+ b << "Shaders/skies/$name/${name}.vert\n"
+ b << ' WorldParameters {\n'
+ b << ' WorldViewProjectionMatrix\n'
+ b << ' }\n'
+ b << ' }\n'
+ return b.toString()
+}
+
+String makeVertexShader(String name, int objects, int clouds) {
+ StringBuilder b = new StringBuilder()
+ b << generatedLine('/*')
+ b << "/*\n * generated vertex shader used by ${name}.j3md\n */\n"
+ b << '#import "Common/ShaderLib/GLSLCompat.glsllib"\n'
+ b << 'attribute vec2 inTexCoord;\n'
+ b << 'attribute vec3 inPosition;\n'
+ b << 'uniform mat4 g_WorldViewProjectionMatrix;\n'
+ b << 'uniform vec2 m_TopCoord;\n'
+ b << 'varying vec2 skyTexCoord;\n'
+ appendObjectDecls(b, objects, true)
+ appendCloudDecls(b, clouds, true, false)
+ b << '\nvoid main(){\n'
+ b << ' skyTexCoord = inTexCoord;\n'
+ if (clouds > 0) {
+ b << ' /*\n'
+ b << ' * Keep cloud coordinates consistent with material sampling.\n'
+ b << ' */\n'
+ }
+ for (int i = 0; i < clouds; ++i) {
+ b << " #ifdef HAS_CLOUDS${i}\n"
+ b << " clouds${i}Coord = inTexCoord "
+ b << "* m_Clouds${i}Scale + m_Clouds${i}Offset;\n"
+ b << ' #endif\n'
+ }
+ for (int i = 0; i < objects; ++i) {
+ b << " #ifdef HAS_OBJECT${i}\n"
+ b << " object${i}Offset = inTexCoord - m_Object${i}Center;\n"
+ b << " object${i}Coord.x = "
+ b << "dot(m_Object${i}TransformU, object${i}Offset);\n"
+ b << " object${i}Coord.y = "
+ b << "dot(m_Object${i}TransformV, object${i}Offset);\n"
+ b << " object${i}Coord += m_TopCoord;\n"
+ b << ' #endif\n'
+ }
+ b << '\n gl_Position = g_WorldViewProjectionMatrix '
+ b << '* vec4(inPosition, 1);\n'
+ b << '}\n'
+ return b.toString()
+}
+
+String makeFragmentShader(String name, int objects, int clouds, boolean glow) {
+ StringBuilder b = new StringBuilder()
+ b << generatedLine('/*')
+ String kind = glow ? 'glow fragment shader' : 'fragment shader'
+ b << "/*\n * generated $kind used by ${name}.j3md\n */\n"
+ b << '#import "Common/ShaderLib/GLSLCompat.glsllib"\n'
+ b << (glow ? 'uniform vec4 m_ClearGlow;\n' : 'uniform vec4 m_ClearColor;\n')
+ b << 'varying vec2 skyTexCoord;\n'
+ if (!glow) {
+ b << '\n#ifdef HAS_STARS\n'
+ b << ' uniform sampler2D m_StarsColorMap;\n'
+ b << '#endif\n'
+ }
+ appendObjectDecls(b, objects, false)
+ appendHazeDecls(b, glow)
+ appendCloudDecls(b, clouds, false, glow)
+ b << mixColorsFunction()
+ b << '\nvoid main() {\n'
+ if (glow) {
+ b << ' vec4 stars = vec4(0.0);\n'
+ } else {
+ b << ' #ifdef HAS_STARS\n'
+ b << ' vec4 stars = texture2D(m_StarsColorMap, skyTexCoord);\n'
+ b << ' #else\n'
+ b << ' vec4 stars = vec4(0.0);\n'
+ b << ' #endif\n'
+ }
+ b << ' vec4 objects = vec4(0.0);\n'
+ appendObjectMain(b, objects, glow)
+ b << '\n vec4 color = mixColors(stars, objects);\n\n'
+ b << (glow ? ' vec4 clear = m_ClearGlow;\n' : ' vec4 clear = m_ClearColor;\n')
+ appendHazeMain(b, glow)
+ b << ' color = mixColors(color, clear);\n'
+ b << ' color.rgb += objects.rgb * objects.a * (1.0 - clear.rgb)'
+ b << ' * clear.a;\n'
+ appendCloudMain(b, clouds, glow)
+ b << '\n\tgl_FragColor = color;\n'
+ b << '}\n'
+ return b.toString()
+}
+
+void appendObjectDecls(StringBuilder b, int objects, boolean vertex) {
+ for (int i = 0; i < objects; ++i) {
+ b << "\n#ifdef HAS_OBJECT${i}\n"
+ if (vertex) {
+ b << " uniform vec2 m_Object${i}Center;\n"
+ b << " uniform vec2 m_Object${i}TransformU;\n"
+ b << " uniform vec2 m_Object${i}TransformV;\n"
+ b << " varying vec2 object${i}Coord;\n"
+ b << " varying vec2 object${i}Offset;\n"
+ } else {
+ b << " uniform vec4 m_Object${i}Color;\n"
+ b << "\tuniform sampler2D m_Object${i}ColorMap;\n"
+ b << "\tvarying vec2 object${i}Coord;\n"
+ }
+ b << '#endif\n'
+ }
+}
+
+void appendCloudDecls(StringBuilder b, int clouds, boolean vertex,
+ boolean glow) {
+ for (int i = 0; i < clouds; ++i) {
+ b << "\n#ifdef HAS_CLOUDS${i}\n"
+ if (vertex) {
+ b << " uniform float m_Clouds${i}Scale;\n"
+ b << " uniform vec2 m_Clouds${i}Offset;\n"
+ b << " varying vec2 clouds${i}Coord;\n"
+ } else {
+ b << " uniform sampler2D m_Clouds${i}AlphaMap;\n"
+ b << " uniform vec4 m_Clouds${i}${glow ? 'Glow' : 'Color'};\n"
+ b << "\tvarying vec2 clouds${i}Coord;\n"
+ }
+ b << '#endif\n'
+ if (!vertex) {
+ b << "#ifdef HAS_CLOUDS${i}_NORMAL\n"
+ b << " uniform sampler2D m_Clouds${i}NormalMap;\n"
+ b << '#endif\n'
+ }
+ }
+}
+
+void appendHazeDecls(StringBuilder b, boolean glow) {
+ b << '\n#ifdef HAS_HAZE\n'
+ b << ' uniform sampler2D m_HazeAlphaMap;\n'
+ b << " uniform vec4 m_Haze${glow ? 'Glow' : 'Color'};\n"
+ b << '#endif\n'
+}
+
+void appendObjectMain(StringBuilder b, int objects, boolean glow) {
+ for (int i = 0; i < objects; ++i) {
+ String objectColor = glow ? "m_Object${i}Glow" : "m_Object${i}Color"
+ b << "\n #ifdef HAS_OBJECT${i}\n"
+ b << " if (floor(object${i}Coord.s) == 0.0 &&\n"
+ b << " floor(object${i}Coord.t) == 0.0) {\n"
+ b << " vec4 object${i} = $objectColor;\n"
+ b << " object${i} *= "
+ b << "texture2D(m_Object${i}ColorMap, object${i}Coord);\n"
+ if (i == 0) {
+ b << " objects = object${i};\n"
+ } else {
+ b << " objects = mixColors(objects, object${i});\n"
+ }
+ b << ' }\n'
+ b << '\t#endif\n'
+ }
+}
+
+void appendHazeMain(StringBuilder b, boolean glow) {
+ String hazeColor = glow ? 'm_HazeGlow' : 'm_HazeColor'
+ b << '\t#ifdef HAS_HAZE\n'
+ b << " vec4 haze = $hazeColor;\n"
+ b << ' haze.a *= texture2D(m_HazeAlphaMap, skyTexCoord).r;\n'
+ b << '\t clear = mixColors(clear, haze);\n'
+ b << '\t#endif\n'
+}
+
+void appendCloudMain(StringBuilder b, int clouds, boolean glow) {
+ for (int i = 0; i < clouds; ++i) {
+ String cloudColor = glow ? "m_Clouds${i}Glow" : "m_Clouds${i}Color"
+ b << "\n\t#ifdef HAS_CLOUDS${i}\n"
+ b << " vec4 clouds${i} = $cloudColor;\n"
+ b << "\t\tclouds${i}.a *= "
+ b << "texture2D(m_Clouds${i}AlphaMap, clouds${i}Coord).r;\n"
+ b << "\t\t#ifdef HAS_CLOUDS${i}_NORMAL\n"
+ b << "\t\t\tvec3 normal${i} = "
+ b << "texture2D(m_Clouds${i}NormalMap, clouds${i}Coord).xyz;\n"
+ b << "\t\t\tnormal${i} = normal${i} * 2.0 - 1.0;\n"
+ b << "\t\t\tfloat relief${i} = clamp(0.90 + normal${i}.z "
+ b << "* 0.15, 0.75, 1.15);\n"
+ b << "\t\t\tclouds${i}.rgb *= relief${i};\n"
+ b << '\t\t#endif\n'
+ b << " color = mixColors(color, clouds${i});\n"
+ b << ' #endif\n'
+ }
+}
+
+String mixColorsFunction() {
+ return '''
+vec4 mixColors(vec4 color0, vec4 color1) {
+ vec4 result;
+ float a0 = color0.a * (1.0 - color1.a);
+ result.a = a0 + color1.a;
+ if (result.a > 0.0) {
+ result.rgb = (a0 * color0.rgb + color1.a * color1.rgb)
+ / result.a;
+ } else {
+ result.rgb = vec3(0.0);
+ }
+ return result;
+}
+'''
+}
+
+String generatedLine(String commentStyle) {
+ if (commentStyle == '//') {
+ return '// Generated by :SkyAssets:skyMaterials. Do not edit by hand.\n'
+ }
+ return '/* Generated by :SkyAssets:skyMaterials. Do not edit by hand. */\n\n'
+}
diff --git a/SkyAssets/src/main/java/jme3utilities/sky/textures/BrightStarCatalogReader.java b/SkyAssets/src/main/java/jme3utilities/sky/textures/BrightStarCatalogReader.java
new file mode 100644
index 0000000..c6946c3
--- /dev/null
+++ b/SkyAssets/src/main/java/jme3utilities/sky/textures/BrightStarCatalogReader.java
@@ -0,0 +1,399 @@
+/*
+ Copyright (c) 2026, Take Some
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+ * Neither the name of the copyright holder nor the names of its contributors
+ may be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package jme3utilities.sky.textures;
+
+import com.jme3.math.FastMath;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileReader;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.TreeSet;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import jme3utilities.MyString;
+import jme3utilities.math.MyMath;
+import jme3utilities.sky.Constants;
+
+/**
+ * Reader for the ASCII Yale Bright Star Catalog.
+ *
+ * @author Take Some
+ */
+final class BrightStarCatalogReader {
+
+ /**
+ * Mutable counters gathered while reading a catalog.
+ */
+ final private static class CatalogStats {
+ /** Number of duplicate entries. */
+ int duplicateEntries;
+ /** Number of missed entries. */
+ int missedEntries;
+ /** Next expected entry id. */
+ int nextEntry = 1;
+ /** Number of catalog entries read. */
+ int readEntries;
+ /** Number of skipped entries. */
+ int skippedEntries;
+ }
+
+ /**
+ * Exception for unexpected invalid data in a catalog entry.
+ */
+ final private static class InvalidEntryException extends Exception {
+ /** serialization identifier. */
+ final static long serialVersionUID = 1L;
+
+ /**
+ * Instantiate the exception.
+ *
+ * @param message descriptive text
+ */
+ InvalidEntryException(String message) {
+ super(message);
+ }
+ }
+
+ /**
+ * Exception for an invalid apparent magnitude in a catalog entry.
+ */
+ final private static class InvalidMagnitudeException extends Exception {
+ /** serialization identifier. */
+ final static long serialVersionUID = 1L;
+ }
+
+ /** Expected id of the last entry in the catalog. */
+ final private static int lastEntryExpected = 9_110;
+ /** Number of degrees from equator to pole. */
+ final private static int maxDeclination = 90;
+ /** Maximum apparent magnitude of all stars in the catalog. */
+ final private static float maxMagnitude = 7.96f;
+ /** Number of minutes in an hour or degree. */
+ final private static int maxMinutes = 60;
+ /** Number of seconds in a minute. */
+ final private static int maxSeconds = 60;
+ /** Minimum apparent magnitude of all stars in the catalog. */
+ final private static float minMagnitude = -1.47f;
+ /** Earth's rate of rotation, in radians per sidereal hour. */
+ final private static float radiansPerHour
+ = FastMath.TWO_PI / Constants.hoursPerDay;
+ /** Message logger for this class. */
+ final private static Logger logger
+ = Logger.getLogger(BrightStarCatalogReader.class.getName());
+
+ /**
+ * Hidden constructor.
+ */
+ private BrightStarCatalogReader() {
+ // do nothing
+ }
+
+ /**
+ * Read the star catalog and collect valid stars.
+ *
+ * @param catalogFilePath filesystem path to the catalog file (not null)
+ * @return new collection of valid stars
+ */
+ static Collection read(String catalogFilePath) {
+ Collection result = new TreeSet<>();
+ File catalogFile = new File(catalogFilePath);
+ FileReader fileReader = null;
+ BufferedReader bufferedReader = null;
+ try {
+ fileReader = new FileReader(catalogFile);
+ bufferedReader = new BufferedReader(fileReader);
+ read(bufferedReader, catalogFilePath, result);
+ } catch (FileNotFoundException exception) {
+ logger.log(Level.SEVERE, "unable to open {0}",
+ MyString.quote(catalogFilePath));
+ throw new RuntimeException(exception);
+ } catch (IOException exception) {
+ logger.log(Level.SEVERE, "unable to read {0}",
+ MyString.quote(catalogFilePath));
+ } catch (InvalidEntryException exception) {
+ logger.log(Level.SEVERE, "", exception);
+ } finally {
+ close(fileReader, bufferedReader, catalogFilePath);
+ }
+
+ return result;
+ }
+
+ /**
+ * Close catalog readers.
+ *
+ * @param fileReader file reader, or null
+ * @param bufferedReader buffered reader, or null
+ * @param catalogFilePath catalog path for logging (not null)
+ */
+ private static void close(FileReader fileReader,
+ BufferedReader bufferedReader, String catalogFilePath) {
+ try {
+ if (fileReader != null) {
+ fileReader.close();
+ }
+ if (bufferedReader != null) {
+ bufferedReader.close();
+ }
+ } catch (IOException exception) {
+ logger.log(Level.WARNING, "unable to close {0}",
+ MyString.quote(catalogFilePath));
+ }
+ }
+
+ /**
+ * Extract a star's declination from a catalog entry.
+ *
+ * @param line text line read from the catalog (not null)
+ * @return angle north of the celestial equator, in degrees
+ */
+ private static float declination(String line)
+ throws InvalidEntryException {
+ assert line != null;
+
+ String dd = line.substring(83, 86);
+ String mm = line.substring(86, 88);
+ String ss = line.substring(88, 90);
+ logger.log(Level.FINE, "{0}d {1}m {2}s", new Object[]{dd, mm, ss});
+
+ int degrees = Integer.parseInt(dd);
+ if (degrees < -maxDeclination || degrees > maxDeclination) {
+ throw new InvalidEntryException(
+ "dec degrees should be between -90 and 90, inclusive");
+ }
+ int minutes = Integer.parseInt(mm);
+ if (minutes < 0 || minutes >= maxMinutes) {
+ throw new InvalidEntryException(
+ "dec minutes should be between 0 and 59, inclusive");
+ }
+ float seconds = Float.parseFloat(ss);
+ if (seconds < 0f || seconds >= maxSeconds) {
+ throw new InvalidEntryException(
+ "dec seconds should be between 0 and 59, inclusive");
+ }
+
+ float result;
+ if (degrees > 0) {
+ result = degrees + minutes / 60f + seconds / 3600f;
+ } else {
+ result = degrees - minutes / 60f - seconds / 3600f;
+ }
+
+ assert result >= -maxDeclination : result;
+ assert result <= maxDeclination : result;
+ logger.log(Level.FINE, "result = {0}", result);
+ return result;
+ }
+
+ /**
+ * Read catalog lines and build up the collection of stars.
+ *
+ * @param bufferedReader reader to use (not null)
+ * @param catalogFilePath catalog path for logging (not null)
+ * @param result collection to populate (not null)
+ */
+ private static void read(BufferedReader bufferedReader,
+ String catalogFilePath, Collection result)
+ throws IOException, InvalidEntryException {
+ assert bufferedReader != null;
+ assert result != null;
+
+ CatalogStats stats = new CatalogStats();
+ for (;;) {
+ String textLine = bufferedReader.readLine();
+ if (textLine == null) {
+ break;
+ }
+ logger.log(Level.FINE, "{0}", textLine);
+ if (textLine.length() < 5) {
+ continue;
+ }
+ String actualPrefix = textLine.substring(0, 4);
+ if (!actualPrefix.matches("[ ]*[0-9]+")) {
+ continue;
+ }
+ ++stats.readEntries;
+
+ int actualEntry = Integer.parseInt(actualPrefix.trim());
+ if (actualEntry > stats.nextEntry) {
+ logger.log(Level.FINE, "missed entries #{0} through #{1}",
+ new Object[]{stats.nextEntry, actualEntry - 1});
+ stats.nextEntry = actualEntry;
+ stats.missedEntries += actualEntry - stats.nextEntry;
+
+ } else if (actualEntry < stats.nextEntry) {
+ logger.log(Level.WARNING,
+ "skipped entry due to duplicate id #{0}",
+ actualEntry);
+ ++stats.skippedEntries;
+ continue;
+ }
+
+ assert actualEntry == stats.nextEntry : stats.nextEntry;
+ Star star = null;
+ try {
+ star = readStar(textLine, stats.nextEntry);
+
+ } catch (InvalidMagnitudeException exception) {
+ logger.log(Level.FINE,
+ "skipped entry #{0} due to invalid magnitude",
+ stats.nextEntry);
+ ++stats.skippedEntries;
+ }
+ if (star != null) {
+ if (result.contains(star)) {
+ logger.log(Level.FINE, "entry #{0} is a duplicate",
+ stats.nextEntry);
+ ++stats.duplicateEntries;
+ } else {
+ boolean success = result.add(star);
+ assert success : stats.nextEntry;
+ }
+ }
+ ++stats.nextEntry;
+ }
+
+ logStats(catalogFilePath, stats, result.size());
+ }
+
+ /**
+ * Construct a new star based on a catalog entry.
+ *
+ * @param textLine line of text read from the catalog (not null)
+ * @param entryId entry id (≥1)
+ * @return new star
+ */
+ private static Star readStar(String textLine, int entryId)
+ throws InvalidEntryException, InvalidMagnitudeException {
+ assert textLine != null;
+ assert entryId >= 1 : entryId;
+
+ if (textLine.length() < 107) {
+ throw new InvalidEntryException("catalog entry is too short");
+ }
+ String magnitudeText = textLine.substring(102, 107);
+ logger.log(Level.FINE, "mag={0}", magnitudeText);
+
+ if (magnitudeText.equals(" ")) {
+ throw new InvalidMagnitudeException();
+ }
+ float apparentMagnitude;
+ try {
+ apparentMagnitude = Float.parseFloat(magnitudeText);
+ } catch (NumberFormatException exception) {
+ logger.log(Level.WARNING, "entry #{0} has invalid magnitude {1}",
+ new Object[]{entryId, MyString.quote(magnitudeText)});
+ throw new InvalidMagnitudeException();
+ }
+ if (apparentMagnitude < minMagnitude
+ || apparentMagnitude > maxMagnitude) {
+ logger.log(Level.WARNING, "entry #{0} has invalid magnitude {1}",
+ new Object[]{entryId, MyString.quote(magnitudeText)});
+ throw new InvalidMagnitudeException();
+ }
+
+ float declinationDegrees = declination(textLine);
+ float declination = MyMath.toRadians(declinationDegrees);
+ float rightAscension = rightAscensionHours(textLine) * radiansPerHour;
+
+ Star result = new Star(rightAscension, declination, apparentMagnitude);
+
+ return result;
+ }
+
+ /**
+ * Log catalog read statistics.
+ *
+ * @param catalogFilePath catalog path (not null)
+ * @param stats catalog counters (not null)
+ * @param collectedStars number of collected stars
+ */
+ private static void logStats(String catalogFilePath, CatalogStats stats,
+ int collectedStars) {
+ int lastEntryRead = stats.nextEntry - 1;
+ if (lastEntryRead != lastEntryExpected) {
+ logger.log(Level.WARNING,
+ "expected last entry to be #{0} but it was actually #{1}",
+ new Object[]{lastEntryExpected, lastEntryRead});
+ }
+ if (stats.missedEntries > 0) {
+ logger.log(Level.WARNING, "missed {0} entries",
+ stats.missedEntries);
+ }
+ logger.log(Level.INFO, "read {0} catalog entries from {1}",
+ new Object[]{stats.readEntries, catalogFilePath});
+ if (stats.duplicateEntries > 0) {
+ logger.log(Level.WARNING, "{0} duplicate entries",
+ stats.duplicateEntries);
+ }
+ if (stats.skippedEntries > 0) {
+ logger.log(Level.WARNING, "{0} entries skipped",
+ stats.skippedEntries);
+ }
+ logger.log(Level.INFO, "collected {0} stars", collectedStars);
+ }
+
+ /**
+ * Extract a star's right ascension from a catalog entry.
+ *
+ * @param line text line read from the catalog (not null)
+ * @return angle east of the March equinox, in hours
+ */
+ private static float rightAscensionHours(String line)
+ throws InvalidEntryException {
+ assert line != null;
+
+ String hh = line.substring(75, 77);
+ String mm = line.substring(77, 79);
+ String ss = line.substring(79, 83);
+ logger.log(Level.FINE, "{0}:{1}:{2}", new Object[]{hh, mm, ss});
+
+ int hours = Integer.parseInt(hh);
+ if (hours < 0 || hours >= Constants.hoursPerDay) {
+ throw new InvalidEntryException(
+ "RA hours should be between 0 and 23, inclusive");
+ }
+ int minutes = Integer.parseInt(mm);
+ if (minutes < 0 || minutes >= maxMinutes) {
+ throw new InvalidEntryException(
+ "RA minutes should be between 0 and 59, inclusive");
+ }
+ float seconds = Float.parseFloat(ss);
+ if (seconds < 0f || seconds >= maxSeconds) {
+ throw new InvalidEntryException(
+ "RA seconds should be between 0 and 59, inclusive");
+ }
+
+ float result = hours + minutes / 60f + seconds / 3600f;
+
+ assert result >= 0f : result;
+ assert result < Constants.hoursPerDay : result;
+ logger.log(Level.FINE, "result = {0}", result);
+ return result;
+ }
+}
diff --git a/SkyAssets/src/main/java/jme3utilities/sky/textures/MakeStarMaps.java b/SkyAssets/src/main/java/jme3utilities/sky/textures/MakeStarMaps.java
index 50c37e3..963f2e6 100644
--- a/SkyAssets/src/main/java/jme3utilities/sky/textures/MakeStarMaps.java
+++ b/SkyAssets/src/main/java/jme3utilities/sky/textures/MakeStarMaps.java
@@ -28,29 +28,15 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
import com.beust.jcommander.JCommander;
import com.beust.jcommander.Parameter;
import com.jme3.math.FastMath;
-import com.jme3.math.Quaternion;
-import com.jme3.math.Vector2f;
-import com.jme3.math.Vector3f;
-import java.awt.Color;
-import java.awt.Graphics2D;
-import java.awt.image.BufferedImage;
import java.awt.image.RenderedImage;
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileNotFoundException;
-import java.io.FileReader;
import java.io.IOException;
import java.util.Collection;
import java.util.TreeSet;
import java.util.logging.Level;
import java.util.logging.Logger;
import jme3utilities.Heart;
-import jme3utilities.MyAsset;
import jme3utilities.MyString;
import jme3utilities.math.MyMath;
-import jme3utilities.math.MyQuaternion;
-import jme3utilities.math.MyVector3f;
-import jme3utilities.mesh.DomeMesh;
import jme3utilities.sky.Constants;
/**
@@ -61,86 +47,14 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* @author Stephen Gold sgold@sonic.net
*/
final class MakeStarMaps {
- // *************************************************************************
- // throwables
-
- /**
- * exception to indicate unexpected invalid data in a catalog entry
- */
- final private static class InvalidEntryException extends Exception {
-
- final static long serialVersionUID = 1L;
-
- /**
- * Instantiate the exception.
- *
- * @param message descriptive text
- */
- InvalidEntryException(String message) {
- super(message);
- }
- }
-
- /**
- * exception to indicate an invalid apparent magnitude in a catalog entry:
- * such entries can be ignored
- */
- final private static class InvalidMagnitudeException extends Exception {
-
- final static long serialVersionUID = 1L;
- }
// *************************************************************************
// constants and loggers
- /**
- * luminosity of the faintest stars to include
- */
- final private static float luminosityCutoff = 0.1f;
- /**
- * maximum (dimmest) apparent magnitude of all stars in the catalog
- */
- final private static float maxMagnitude = 7.96f;
- /**
- * minimum (brightest) apparent magnitude of all stars in the catalog
- */
- final private static float minMagnitude = -1.47f;
- /**
- * luminosity ratio between successive stellar magnitudes (5th root of 100)
- */
- final private static float pogsonsRatio = FastMath.pow(100f, 0.2f);
/**
* Earth's rate of rotation (radians per sidereal hour)
*/
final private static float radiansPerHour
= FastMath.TWO_PI / Constants.hoursPerDay;
- /**
- * expected id of the last entry in the catalog
- */
- final private static int lastEntryExpected = 9_110;
- /**
- * number of degrees from equator to pole
- */
- final private static int maxDeclination = 90;
- /**
- * number of minutes in an hour or degree
- */
- final private static int maxMinutes = 60;
- /**
- * number of seconds in a minute
- */
- final private static int maxSeconds = 60;
- /**
- * number of points per ellipse
- */
- final private static int ellipseNumPoints = 32;
- /**
- * x-coordinates used to draw an ellipse
- */
- final private static int[] ellipseXs = new int[ellipseNumPoints];
- /**
- * y-coordinates used to draw an ellipse
- */
- final private static int[] ellipseYs = new int[ellipseNumPoints];
/**
* message logger for this class
*/
@@ -187,10 +101,6 @@ final private static class InvalidMagnitudeException extends Exception {
* stars read from the catalog
*/
final private static Collection stars = new TreeSet<>();
- /**
- * sample dome mesh for calculating texture coordinates
- */
- final private static DomeMesh domeMesh = new DomeMesh(3, 2);
/**
* name of preset
*/
@@ -260,159 +170,6 @@ public static void main(String[] arguments) {
// *************************************************************************
// private methods
- /**
- * Calculate the texture coordinates of a point that lies in the specified
- * direction from the center of the cube.
- *
- * @param direction (length>0, unaffected)
- * @param faceIndex (≥0, <6) which face of the cube
- * @return a new vector, or null if direction is too far outside the face
- */
- private static Vector2f cubeUV(Vector3f direction, int faceIndex) {
- assert direction != null;
- assert !MyVector3f.isZero(direction);
- assert faceIndex >= 0 : faceIndex;
- assert faceIndex < 6 : faceIndex;
-
- Vector3f faceDir = MyAsset.copyFaceDirection(faceIndex);
- Vector3f norm = direction.normalize();
- float dot = faceDir.dot(norm);
- if (dot < 0.5f) { // way outside the face
- return null;
- }
-
- // project outward to the plane of the face
- norm.divideLocal(dot);
-
- // convert to texture coordinates
- Vector3f uDir = MyAsset.copyUDirection(faceIndex);
- Vector3f vDir = MyAsset.copyVDirection(faceIndex);
- float u = 0.5f * (1f + uDir.dot(norm));
- float v = 0.5f * (1f + vDir.dot(norm));
- Vector2f uv = new Vector2f(u, v);
-
- return uv;
- }
-
- /**
- * Extract a star's declination from a catalog entry.
- *
- * @param line of text read from the catalog (not null)
- * @return angle north of the celestial equator (in degrees, ≤90,
- * ≥-90)
- */
- private static float declination(String line)
- throws InvalidEntryException {
- assert line != null;
-
- // Extract declination components from the line of text.
- String dd = line.substring(83, 86);
- String mm = line.substring(86, 88);
- String ss = line.substring(88, 90);
- logger.log(Level.FINE, "{0}d {1}m {2}s", new Object[]{dd, mm, ss});
-
- // sanity checks
- int degrees = Integer.parseInt(dd);
- if (degrees < -maxDeclination || degrees > maxDeclination) {
- throw new InvalidEntryException(
- "dec degrees should be between -90 and 90, inclusive");
- }
- int minutes = Integer.parseInt(mm);
- if (minutes < 0 || minutes >= maxMinutes) {
- throw new InvalidEntryException(
- "dec minutes should be between 0 and 59, inclusive");
- }
- float seconds = Float.parseFloat(ss);
- if (seconds < 0f || seconds >= maxSeconds) {
- throw new InvalidEntryException(
- "dec seconds should be between 0 and 59, inclusive");
- }
-
- // Convert to an angle.
- float result; // in degrees
- if (degrees > 0) {
- result = degrees + minutes / 60f + seconds / 3600f;
- } else {
- result = degrees - minutes / 60f - seconds / 3600f;
- }
-
- assert result >= -maxDeclination : result;
- assert result <= maxDeclination : result;
- logger.log(Level.FINE, "result = {0}", result);
- return result;
- }
-
- /**
- * Generate 6 starry sky texture maps for a cube.
- *
- * @param latitude radians north of the equator (≤Pi/2, ≥-Pi/2)
- * @param siderealTime radians since sidereal midnight (<2*Pi, ≥0)
- * @param textureSize size of each texture map (pixels per side, >2)
- * @return new instance
- */
- private static RenderedImage[] generateCubeMap(
- float latitude, float siderealTime, int textureSize) {
- assert latitude >= -FastMath.HALF_PI : latitude;
- assert latitude <= FastMath.HALF_PI : latitude;
- assert siderealTime >= 0f : siderealTime;
- assert siderealTime < FastMath.TWO_PI : siderealTime;
- assert textureSize > 2 : textureSize;
-
- // Create a blank, grayscale buffered image for each texture map.
- BufferedImage[] maps = new BufferedImage[6];
- for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
- maps[faceIndex] = new BufferedImage(
- textureSize, textureSize, BufferedImage.TYPE_BYTE_GRAY);
- }
-
- // Plot individual stars on the images, starting with the faintest.
- int plotCount = 0;
- for (Star star : stars) {
- boolean success = plotStarOnCube(
- maps, star, latitude, siderealTime, textureSize);
- if (success) {
- ++plotCount;
- }
- }
- logger.log(Level.FINE, "plotted {0} stars", plotCount);
-
- return maps;
- }
-
- /**
- * Generate a starry sky texture map for a dome.
- *
- * @param latitude radians north of the equator (≤Pi/2, ≥-Pi/2)
- * @param siderealTime radians since sidereal midnight (<2*Pi, ≥0)
- * @param textureSize size of the texture map (pixels per side, >2)
- * @return new instance
- */
- private static RenderedImage generateDomeMap(
- float latitude, float siderealTime, int textureSize) {
- assert latitude >= -FastMath.HALF_PI : latitude;
- assert latitude <= FastMath.HALF_PI : latitude;
- assert siderealTime >= 0f : siderealTime;
- assert siderealTime < FastMath.TWO_PI : siderealTime;
- assert textureSize > 2 : textureSize;
-
- // Create a blank, grayscale buffered image for the texture map.
- BufferedImage map = new BufferedImage(
- textureSize, textureSize, BufferedImage.TYPE_BYTE_GRAY);
-
- // Plot individual stars on the image, starting with the faintest.
- int plotCount = 0;
- for (Star star : stars) {
- boolean success = plotStarOnDome(
- map, star, latitude, siderealTime, textureSize);
- if (success) {
- ++plotCount;
- }
- }
- logger.log(Level.FINE, "plotted {0} stars", plotCount);
-
- return map;
- }
-
/**
* Generate starry sky texture map(s) for the specified preset.
*
@@ -435,8 +192,8 @@ private static void generateMap(StarMapPreset preset) {
float siderealTime = siderealHour * radiansPerHour;
if (forCube) { // Generate 6 texture maps for a cube.
- RenderedImage[] images = generateCubeMap(latitude, siderealTime,
- textureSize);
+ RenderedImage[] images = StarMapRenderer.generateCubeMap(
+ stars, latitude, siderealTime, textureSize);
assert images.length == 6 : images.length;
for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
String filePath = String.format("%s/%s/%s_%s%d.png",
@@ -451,8 +208,8 @@ private static void generateMap(StarMapPreset preset) {
}
} else { // Generate a texture map for a dome.
- RenderedImage image = generateDomeMap(latitude, siderealTime,
- textureSize);
+ RenderedImage image = StarMapRenderer.generateDomeMap(
+ stars, latitude, siderealTime, textureSize);
String filePath = String.format("%s/%s.png", outputDirPath,
preset.textureFileName());
try {
@@ -463,627 +220,12 @@ private static void generateMap(StarMapPreset preset) {
}
}
- /**
- * Plot a four-pointed star shape on a texture map.
- *
- * @param map texture map (not null)
- * @param luminosity star's relative luminosity (in terms of pure white
- * pixels, ≤37, >0)
- * @param textureSize size of the texture map (pixels per side, >2)
- * @param uv star's texture coordinates (not null)
- * @return true if the star was successfully plotted, otherwise false
- */
- private static boolean plot4PointStar(BufferedImage map, float luminosity,
- int textureSize, Vector2f uv) {
- assert luminosity > 0f : luminosity;
- assert luminosity <= 37f : luminosity;
- assert textureSize > 2 : textureSize;
- assert uv != null;
- /*
- * Convert the star's luminosity into a shape and pixel color.
- *
- * The shape must be big enough to ensure that the pixels will not be
- * oversaturated. For instance, a star with luminosity=4.1
- * must fill at least 5 pixels.
- */
- int minPixels = (int) FastMath.ceil(luminosity);
- assert minPixels >= 1 : minPixels;
- /*
- * Star shapes consist of a square portion (up to 5x5 pixels)
- * plus optional rays. Rays are used only with odd-sized squares;
- * they add either 1 or 3 pixels to each side of the square.
- * In other words, they add either 4 or 12 pixels.
- */
- int raySize;
- int squareSize;
- if (minPixels == 1) {
- raySize = 0;
- squareSize = 1;
- } else if (minPixels <= 4) {
- raySize = 0;
- squareSize = 2;
- } else if (minPixels <= 5) {
- raySize = 1;
- squareSize = 1;
- } else if (minPixels <= 9) {
- raySize = 0;
- squareSize = 3;
- } else if (minPixels <= 13) {
- raySize = 1;
- squareSize = 3;
- } else if (minPixels <= 16) {
- raySize = 0;
- squareSize = 4;
- } else if (minPixels <= 21) {
- raySize = 3;
- squareSize = 3;
- } else if (minPixels <= 29) {
- raySize = 1;
- squareSize = 5;
- } else if (minPixels <= 37) {
- raySize = 3;
- squareSize = 5;
- } else {
- logger.log(Level.SEVERE, "no shape contains {0} pixels", minPixels);
- return false;
- }
- int numPixels = squareSize * squareSize + 4 * raySize;
- assert numPixels >= minPixels : minPixels;
- int brightness = Math.round(255f * luminosity / numPixels);
- assert brightness >= 0 : brightness;
- assert brightness <= 255 : brightness;
- // TODO tint based on spectral type
- Color color = new Color(brightness, brightness, brightness);
- /*
- * Convert the texture coordinates into (x, y) image coordinates of
- * the square's upper-left pixel.
- */
- float u = uv.x;
- float v = uv.y;
- float cornerOffset = 0.5f * (squareSize - 1);
- int x = Math.round(u * textureSize - cornerOffset);
- int y = Math.round(v * textureSize - cornerOffset);
-
- // Plot the star onto the texture map.
- Graphics2D graphics = map.createGraphics();
- graphics.setColor(color);
- graphics.fillRect(x, y, squareSize, squareSize);
- if (raySize == 0) {
- return true;
- }
-
- assert MyMath.isOdd(squareSize) : squareSize;
- int halfSize = (squareSize - 1) / 2;
- switch (raySize) {
- case 1:
- graphics.fillRect(x - 1, y + halfSize, 1, 1);
- graphics.fillRect(x + halfSize, y - 1, 1, 1);
- graphics.fillRect(x + halfSize, y + squareSize, 1, 1);
- graphics.fillRect(x + squareSize, y + halfSize, 1, 1);
- break;
-
- case 3:
- graphics.fillRect(x - 1, y + halfSize - 1, 1, 3);
- graphics.fillRect(x + halfSize - 1, y - 1, 3, 1);
- graphics.fillRect(x + halfSize - 1, y + squareSize, 3, 1);
- graphics.fillRect(x + squareSize, y + halfSize - 1, 1, 3);
- break;
-
- default:
- assert false : raySize;
- }
- return true;
- }
-
- /**
- * Draw an ellipse -- a circle stretched to compensate for UV distortion
- * near the rim of the dome.
- *
- * @param map texture map (not null)
- * @param luminosity star's relative luminosity (in terms of pure white
- * pixels, >0)
- * @param textureSize size of the texture map (pixels per side, >2)
- * @param uv star's texture coordinates (not null)
- */
- private static void plotEllipseForDome(BufferedImage map, float luminosity,
- int textureSize, Vector2f uv) {
- assert map != null;
- assert luminosity > 0f : luminosity;
- assert textureSize > 2 : textureSize;
- assert uv != null;
- float u = uv.x;
- float v = uv.y;
- assert u >= Constants.uvMin : u;
- assert u <= Constants.uvMax : u;
- assert v >= Constants.uvMin : v;
- assert v <= Constants.uvMax : v;
-
- Vector2f offset = uv.subtract(Constants.topUV);
- float topDist = offset.length();
- float xDir;
- float yDir;
- if (topDist > 0f) {
- xDir = offset.x / topDist;
- yDir = offset.y / topDist;
- } else {
- xDir = 1f;
- yDir = 0f;
- }
- float stretchFactor = 1f
- + Constants.stretchCoefficient * topDist * topDist;
- float a = FastMath.sqrt(luminosity * stretchFactor / FastMath.PI);
- float b = a / stretchFactor;
-
- for (int i = 0; i < ellipseNumPoints; ++i) {
- float theta = FastMath.TWO_PI * i / ellipseNumPoints;
- float da = a * FastMath.cos(theta);
- float db = b * FastMath.sin(theta);
- float dx = db * xDir + da * yDir;
- float dy = db * yDir - da * xDir;
- int x = Math.round(u * textureSize + dx);
- int y = Math.round(v * textureSize + dy);
- ellipseXs[i] = x;
- ellipseYs[i] = y;
- }
- Graphics2D graphics = map.createGraphics();
- graphics.setColor(Color.WHITE); // TODO tint based on spectral type
- graphics.fillPolygon(ellipseXs, ellipseYs, ellipseNumPoints);
- }
-
- /**
- * Draw an ellipse -- a circle stretched to compensate for UV distortion
- * near the edges of the quad.
- *
- * @param map texture map (not null)
- * @param luminosity star's relative luminosity (>0)
- * @param textureSize size of the texture map (pixels per side, >2)
- * @param worldDirection the star's world coordinates (length=1)
- * @param faceIndex which face of the cube (≥0, <6)
- */
- private static void plotEllipseForQuad(BufferedImage map, float luminosity,
- int textureSize, Vector3f worldDirection, int faceIndex) {
- assert map != null;
- assert luminosity > 0f : luminosity;
- assert textureSize > 2 : textureSize;
- assert worldDirection != null;
- assert faceIndex >= 0 : faceIndex;
- assert faceIndex < 6 : faceIndex;
-
- Vector3f basis1 = worldDirection.clone();
- Vector3f basis2 = new Vector3f();
- Vector3f basis3 = new Vector3f();
- MyVector3f.generateBasis(basis1, basis2, basis3);
- float numPixels = textureSize * textureSize;
- float area = luminosity / numPixels;
- float r = 1.2f * FastMath.sqrt(area);
-
- Vector3f p = new Vector3f();
- for (int i = 0; i < ellipseNumPoints; ++i) {
- float theta = FastMath.TWO_PI * i / ellipseNumPoints;
- float rCos = r * FastMath.cos(theta);
- float rSin = r * FastMath.sin(theta);
- p.scaleAdd(rCos, basis2, basis1);
- p.scaleAdd(rSin, basis3, p);
- Vector2f uv = cubeUV(p, faceIndex);
- if (uv == null) {
- return;
- }
- int x = Math.round(uv.x * textureSize);
- int y = Math.round(uv.y * textureSize);
- ellipseXs[i] = x;
- ellipseYs[i] = y;
- }
- Graphics2D graphics = map.createGraphics();
- graphics.setColor(Color.WHITE); // TODO tint based on spectral type
- graphics.fillPolygon(ellipseXs, ellipseYs, ellipseNumPoints);
- }
-
- /**
- * Plot a star's position at the specified time onto a cube.
- *
- * @param maps texture maps for 6 cube faces (not null, modified)
- * @param star star to plot (not null)
- * @param latitude radians north of the equator (≤Pi/2, ≥-Pi/2)
- * @param siderealTime radians since sidereal midnight (<2*Pi, ≥0)
- * @param textureSize size of the texture map (pixels per side, >2)
- * @return true if the star was successfully plotted, otherwise false
- */
- private static boolean plotStarOnCube(BufferedImage[] maps, Star star,
- float latitude, float siderealTime, int textureSize) {
- assert maps != null;
- assert maps.length == 6 : maps.length;
- assert star != null;
- assert latitude >= -FastMath.HALF_PI : latitude;
- assert latitude <= FastMath.HALF_PI : latitude;
- assert siderealTime >= 0f : siderealTime;
- assert siderealTime < FastMath.TWO_PI : siderealTime;
- assert textureSize > 2 : textureSize;
-
- Vector3f equatorial = star.getEquatorialLocation(siderealTime);
- /*
- * Convert equatorial coordinates to world coordinates, where:
- * +X points to the north horizon
- * +Y points to the zenith
- * +Z points to the east horizon
- *
- * The conversion consists of a (latitude - Pi/2) rotation about the Y
- * (east) axis followed by permutation of the axes.
- */
- float coLatitude = FastMath.HALF_PI - latitude;
- Quaternion rotation = new Quaternion();
- rotation.fromAngleNormalAxis(-coLatitude, Vector3f.UNIT_Y);
- Vector3f rotated = MyQuaternion.rotate(rotation, equatorial, null);
- assert rotated.isUnitVector() : rotated;
- Vector3f world = new Vector3f(-rotated.x, rotated.z, rotated.y);
-
- float apparentMagnitude = star.getApparentMagnitude();
- boolean success = plotStarOnCube(maps, apparentMagnitude,
- textureSize, world);
-
- return success;
- }
-
- /**
- * Plot a star onto a texture map for a cube.
- *
- * @param maps texture maps for 6 cube faces (not null, modified)
- * @param apparentMagnitude the star's brightness (log scale)
- * @param textureSize size of the texture map (pixels per side, <2)
- * @param worldDirection the star's world coordinates (length=1)
- * @return true if the star was successfully plotted, otherwise false
- */
- private static boolean plotStarOnCube(BufferedImage[] maps,
- float apparentMagnitude, int textureSize, Vector3f worldDirection) {
- assert maps != null;
- assert maps.length == 6 : maps.length;
- assert worldDirection != null;
- assert worldDirection.isUnitVector() : worldDirection;
- assert textureSize > 2 : textureSize;
-
- // Convert apparent magnitude to relative luminosity.
- float resolution = textureSize / 2_048f;
- float luminosity0 = 100f * resolution * resolution;
- float luminosity = luminosity0 * 1.5f
- * FastMath.pow(pogsonsRatio, -apparentMagnitude);
- if (luminosity < luminosityCutoff) {
- return false;
- }
-
- boolean success = false;
- for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
- /*
- * Convert world direction to texture coordinates on this
- * face of the cube.
- */
- Vector2f uv = cubeUV(worldDirection, faceIndex);
- if (uv != null) {
- BufferedImage map = maps[faceIndex];
-
- if (luminosity <= 37f) {
- boolean success2 = plot4PointStar(map, luminosity,
- textureSize, uv);
- if (success2) {
- success = true;
- }
- } else {
- plotEllipseForQuad(map, luminosity, textureSize,
- worldDirection, faceIndex);
- success = true;
- }
- }
- }
- return success;
- }
-
- /**
- * Plot a star's position at the specified time onto a texture map for a
- * dome.
- *
- * @param map texture map (not null, modified)
- * @param star star to plot (not null)
- * @param latitude radians north of the equator (≤Pi/2, ≥-Pi/2)
- * @param siderealTime radians since sidereal midnight (<2*Pi, ≥0)
- * @param textureSize size of the texture map (pixels per side, >2)
- * @return true if the star was successfully plotted, otherwise false
- */
- private static boolean plotStarOnDome(BufferedImage map, Star star,
- float latitude, float siderealTime, int textureSize) {
- assert map != null;
- assert star != null;
- assert latitude >= -FastMath.HALF_PI : latitude;
- assert latitude <= FastMath.HALF_PI : latitude;
- assert siderealTime >= 0f : siderealTime;
- assert siderealTime < FastMath.TWO_PI : siderealTime;
- assert textureSize > 2 : textureSize;
-
- Vector3f equatorial = star.getEquatorialLocation(siderealTime);
- /*
- * Convert equatorial coordinates to world coordinates, where:
- * +X points to the north horizon
- * +Y points to the zenith
- * +Z points to the east horizon
- *
- * The conversion consists of a (latitude - Pi/2) rotation about the Y
- * (east) axis followed by permutation of the axes.
- */
- float coLatitude = FastMath.HALF_PI - latitude;
- Quaternion rotation = new Quaternion();
- rotation.fromAngleNormalAxis(-coLatitude, Vector3f.UNIT_Y);
- Vector3f rotated = MyQuaternion.rotate(rotation, equatorial, null);
- assert rotated.isUnitVector() : rotated;
- if (rotated.z < 0f) { // The star lies below the horizon, so skip it.
- return false;
- }
- Vector3f world = new Vector3f(-rotated.x, rotated.z, rotated.y);
-
- float apparentMagnitude = star.getApparentMagnitude();
- boolean success = plotStarOnDome(
- map, apparentMagnitude, textureSize, world);
-
- return success;
- }
-
- /**
- * Plot a star on a texture map for a dome.
- *
- * @param map texture map (not null)
- * @param apparentMagnitude the star's brightness
- * @param textureSize size of the texture map (pixels per side, <2)
- * @param worldDirection the star's world coordinates (length=1)
- * @return true if the star was successfully plotted, otherwise false
- */
- private static boolean plotStarOnDome(BufferedImage map,
- float apparentMagnitude, int textureSize, Vector3f worldDirection) {
- assert map != null;
- assert worldDirection != null;
- assert worldDirection.isUnitVector() : worldDirection;
- assert textureSize > 2 : textureSize;
-
- // Convert apparent magnitude to relative luminosity.
- float resolution = textureSize / 2_048f;
- float luminosity0 = 37f * resolution * resolution;
- float luminosity
- = luminosity0 * FastMath.pow(pogsonsRatio, -apparentMagnitude);
- if (luminosity < luminosityCutoff) {
- return false;
- }
-
- // Convert world direction to texture coordinates on a dome.
- Vector2f uv = domeMesh.directionUV(worldDirection);
-
- if (luminosity <= 37f) {
- boolean success = plot4PointStar(map, luminosity, textureSize, uv);
- return success;
- }
- plotEllipseForDome(map, luminosity, textureSize, uv);
- return true;
- }
-
/**
* Read the star catalog and add each valid star to the collection.
*/
private static void readCatalog() {
- File catalogFile = new File(catalogFilePath);
- FileReader fileReader = null;
- BufferedReader bufferedReader = null;
- try {
- fileReader = new FileReader(catalogFile);
- bufferedReader = new BufferedReader(fileReader);
- readCatalog(bufferedReader);
- } catch (FileNotFoundException exception) {
- logger.log(Level.SEVERE, "unable to open {0}",
- MyString.quote(catalogFilePath));
- throw new RuntimeException(exception);
- } catch (IOException exception) {
- logger.log(Level.SEVERE, "unable to read {0}",
- MyString.quote(catalogFilePath));
- } catch (InvalidEntryException exception) {
- logger.log(Level.SEVERE, "", exception);
- } finally {
- try {
- if (fileReader != null) {
- fileReader.close();
- }
- if (bufferedReader != null) {
- bufferedReader.close();
- }
- } catch (IOException exception) {
- logger.log(Level.WARNING, "unable to close {0}",
- MyString.quote(catalogFilePath));
- }
- }
- }
-
- /**
- * Read the catalog line by line and use the data therein to build up the
- * collection of stars.
- *
- * @param bufferedReader the reader to use (not null)
- */
- private static void readCatalog(BufferedReader bufferedReader)
- throws IOException, InvalidEntryException {
- assert bufferedReader != null;
-
- int duplicateEntries = 0;
- int nextEntry = 1;
- int missedEntries = 0;
- int readEntries = 0;
- int skippedEntries = 0;
- for (;;) {
- String textLine;
- textLine = bufferedReader.readLine();
- if (textLine == null) {
- // Might have reached the end of the catalog file.
- break;
- }
- logger.log(Level.FINE, "{0}", textLine);
- /*
- * If the line does not resemble a catalog entry,
- * then silently ignore it.
- */
- if (textLine.length() < 5) {
- continue;
- }
- String actualPrefix = textLine.substring(0, 4);
- if (!actualPrefix.matches("[ ]*[0-9]+")) {
- continue;
- }
- ++readEntries;
-
- // Cope with missing/duplicate entry ids.
- int actualEntry = Integer.parseInt(actualPrefix.trim());
- if (actualEntry > nextEntry) {
- logger.log(Level.FINE, "missed entries #{0} through #{1}",
- new Object[]{nextEntry, actualEntry - 1});
- nextEntry = actualEntry;
- missedEntries += actualEntry - nextEntry;
-
- } else if (actualEntry < nextEntry) {
- logger.log(Level.WARNING,
- "skipped entry due to duplicate id #{0}",
- actualEntry);
- ++skippedEntries;
- continue;
- }
-
- assert actualEntry == nextEntry : nextEntry;
- Star star = null;
- try {
- star = readStar(textLine, nextEntry);
-
- } catch (InvalidMagnitudeException exception) {
- logger.log(Level.FINE,
- "skipped entry #{0} due to invalid magnitude",
- nextEntry);
- ++skippedEntries;
- }
- if (star != null) {
- if (stars.contains(star)) {
- logger.log(Level.FINE, "entry #{0} is a duplicate",
- nextEntry);
- ++duplicateEntries;
- } else {
- boolean success = stars.add(star);
- assert success : nextEntry;
- }
- }
- ++nextEntry;
- }
-
- // Verify that the entire catalog was read.
- int lastEntryRead = nextEntry - 1;
- if (lastEntryRead != lastEntryExpected) {
- logger.log(Level.WARNING,
- "expected last entry to be #{0} but it was actually #{1}",
- new Object[]{lastEntryExpected, lastEntryRead});
- }
-
- // Log statistics.
- if (missedEntries > 0) {
- logger.log(Level.WARNING, "missed {0} entries", missedEntries);
- }
- logger.log(Level.INFO, "read {0} catalog entries from {1}",
- new Object[]{readEntries, catalogFilePath});
- if (duplicateEntries > 0) {
- logger.log(Level.WARNING, "{0} duplicate entries",
- duplicateEntries);
- }
- if (skippedEntries > 0) {
- logger.log(Level.WARNING, "{0} entries skipped", skippedEntries);
- }
- logger.log(Level.INFO, "collected {0} stars", stars.size());
- }
-
- /**
- * Construct a new star based on a catalog entry.
- *
- * @param textLine line of text read from the catalog (not null)
- * @param entryId (≥1)
- * @return new instance
- */
- private static Star readStar(String textLine, int entryId)
- throws InvalidEntryException, InvalidMagnitudeException {
- assert textLine != null;
- assert entryId >= 1 : entryId;
-
- // Extract the apparent magnitude field from the line of text.
- if (textLine.length() < 107) {
- throw new InvalidEntryException("catalog entry is too short");
- }
- String magnitudeText = textLine.substring(102, 107);
- logger.log(Level.FINE, "mag={0}", magnitudeText);
-
- // sanity checks on the magnitude
- if (magnitudeText.equals(" ")) {
- throw new InvalidMagnitudeException();
- }
- float apparentMagnitude;
- try {
- apparentMagnitude = Float.parseFloat(magnitudeText);
- } catch (NumberFormatException exception) {
- logger.log(Level.WARNING, "entry #{0} has invalid magnitude {1}",
- new Object[]{entryId, MyString.quote(magnitudeText)});
- throw new InvalidMagnitudeException();
- }
- if (apparentMagnitude < minMagnitude
- || apparentMagnitude > maxMagnitude) {
- logger.log(Level.WARNING, "entry #{0} has invalid magnitude {1}",
- new Object[]{entryId, MyString.quote(magnitudeText)});
- throw new InvalidMagnitudeException();
- }
- /*
- * Compute the star's equatorial coordinates
- * and convert them to radians.
- */
- float declinationDegrees = declination(textLine);
- float declination = MyMath.toRadians(declinationDegrees);
- float rightAscension = rightAscensionHours(textLine) * radiansPerHour;
-
- // Instantiate the star.
- Star result = new Star(rightAscension, declination, apparentMagnitude);
-
- return result;
+ stars.clear();
+ stars.addAll(BrightStarCatalogReader.read(catalogFilePath));
}
- /**
- * Extract a star's right ascension from a catalog entry.
- *
- * @param line of text read from the catalog (not null)
- * @return angle east of the March equinox (in hours, <24, ≥0)
- */
- private static float rightAscensionHours(String line)
- throws InvalidEntryException {
- assert line != null;
-
- // Extract right ascension components from the line of text.
- String hh = line.substring(75, 77);
- String mm = line.substring(77, 79);
- String ss = line.substring(79, 83);
- logger.log(Level.FINE, "{0}:{1}:{2}", new Object[]{hh, mm, ss});
-
- // sanity checks
- int hours = Integer.parseInt(hh);
- if (hours < 0 || hours >= Constants.hoursPerDay) {
- throw new InvalidEntryException(
- "RA hours should be between 0 and 23, inclusive");
- }
- int minutes = Integer.parseInt(mm);
- if (minutes < 0 || minutes >= maxMinutes) {
- throw new InvalidEntryException(
- "RA minutes should be between 0 and 59, inclusive");
- }
- float seconds = Float.parseFloat(ss);
- if (seconds < 0f || seconds >= maxSeconds) {
- throw new InvalidEntryException(
- "RA seconds should be between 0 and 59, inclusive");
- }
-
- // Convert to an angle.
- float result = hours + minutes / 60f + seconds / 3600f; // in hours
-
- assert result >= 0f : result;
- assert result < Constants.hoursPerDay : result;
- logger.log(Level.FINE, "result = {0}", result);
- return result;
- }
}
diff --git a/SkyAssets/src/main/java/jme3utilities/sky/textures/StarMapPlotter.java b/SkyAssets/src/main/java/jme3utilities/sky/textures/StarMapPlotter.java
new file mode 100644
index 0000000..ab6f4cb
--- /dev/null
+++ b/SkyAssets/src/main/java/jme3utilities/sky/textures/StarMapPlotter.java
@@ -0,0 +1,347 @@
+/*
+ Copyright (c) 2026, Take Some
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+ * Neither the name of the copyright holder nor the names of its contributors
+ may be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package jme3utilities.sky.textures;
+
+import com.jme3.math.FastMath;
+import com.jme3.math.Vector2f;
+import com.jme3.math.Vector3f;
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.image.BufferedImage;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import jme3utilities.math.MyMath;
+import jme3utilities.math.MyVector3f;
+import jme3utilities.mesh.DomeMesh;
+import jme3utilities.sky.Constants;
+
+/**
+ * Draws stars onto dome and cube star-map textures.
+ *
+ * @author Take Some
+ */
+final class StarMapPlotter {
+ /** Luminosity of the faintest stars to draw. */
+ final private static float luminosityCutoff = 0.1f;
+ /** Number of points per ellipse. */
+ final private static int ellipseNumPoints = 32;
+ /** Luminosity ratio between successive stellar magnitudes. */
+ final private static float pogsonsRatio = FastMath.pow(100f, 0.2f);
+ /** Sample dome mesh for calculating texture coordinates. */
+ final private static DomeMesh domeMesh = new DomeMesh(3, 2);
+ /** x-coordinates used to draw an ellipse. */
+ final private static int[] ellipseXs = new int[ellipseNumPoints];
+ /** y-coordinates used to draw an ellipse. */
+ final private static int[] ellipseYs = new int[ellipseNumPoints];
+ /** Message logger for this class. */
+ final private static Logger logger
+ = Logger.getLogger(StarMapPlotter.class.getName());
+
+ /**
+ * Hidden constructor.
+ */
+ private StarMapPlotter() {
+ // do nothing
+ }
+
+ /**
+ * Plot a star's position at the specified time onto a cube.
+ *
+ * @param maps texture maps for 6 cube faces (not null, modified)
+ * @param star star to plot (not null)
+ * @param latitude radians north of the equator (≤Pi/2, ≥-Pi/2)
+ * @param siderealTime radians since sidereal midnight (<2*Pi, ≥0)
+ * @param textureSize size of the texture map (pixels per side, >2)
+ * @return true if the star was plotted, otherwise false
+ */
+ static boolean plotOnCube(BufferedImage[] maps, Star star,
+ float latitude, float siderealTime, int textureSize) {
+ Vector3f world = StarMapProjection.worldDirection(
+ star, latitude, siderealTime);
+ float apparentMagnitude = star.getApparentMagnitude();
+ boolean result = plotOnCube(
+ maps, apparentMagnitude, textureSize, world);
+
+ return result;
+ }
+
+ /**
+ * Plot a star's position at the specified time onto a dome.
+ *
+ * @param map texture map (not null, modified)
+ * @param star star to plot (not null)
+ * @param latitude radians north of the equator (≤Pi/2, ≥-Pi/2)
+ * @param siderealTime radians since sidereal midnight (<2*Pi, ≥0)
+ * @param textureSize size of the texture map (pixels per side, >2)
+ * @return true if the star was plotted, otherwise false
+ */
+ static boolean plotOnDome(BufferedImage map, Star star,
+ float latitude, float siderealTime, int textureSize) {
+ Vector3f world = StarMapProjection.worldDirection(
+ star, latitude, siderealTime);
+ if (world.y < 0f) {
+ return false;
+ }
+
+ float apparentMagnitude = star.getApparentMagnitude();
+ boolean result = plotOnDome(
+ map, apparentMagnitude, textureSize, world);
+
+ return result;
+ }
+
+ /**
+ * Plot a four-pointed star shape on a texture map.
+ *
+ * @param map texture map (not null)
+ * @param luminosity star luminosity in white-pixel units (≤37, >0)
+ * @param textureSize texture size in pixels per side (>2)
+ * @param uv star texture coordinates (not null)
+ * @return true if the star was plotted, otherwise false
+ */
+ private static boolean plot4PointStar(BufferedImage map, float luminosity,
+ int textureSize, Vector2f uv) {
+ assert luminosity > 0f : luminosity;
+ assert luminosity <= 37f : luminosity;
+ assert textureSize > 2 : textureSize;
+ assert uv != null;
+
+ int minPixels = (int) FastMath.ceil(luminosity);
+ assert minPixels >= 1 : minPixels;
+ int raySize;
+ int squareSize;
+ if (minPixels == 1) {
+ raySize = 0;
+ squareSize = 1;
+ } else if (minPixels <= 4) {
+ raySize = 0;
+ squareSize = 2;
+ } else if (minPixels <= 5) {
+ raySize = 1;
+ squareSize = 1;
+ } else if (minPixels <= 9) {
+ raySize = 0;
+ squareSize = 3;
+ } else if (minPixels <= 13) {
+ raySize = 1;
+ squareSize = 3;
+ } else if (minPixels <= 16) {
+ raySize = 0;
+ squareSize = 4;
+ } else if (minPixels <= 21) {
+ raySize = 3;
+ squareSize = 3;
+ } else if (minPixels <= 29) {
+ raySize = 1;
+ squareSize = 5;
+ } else if (minPixels <= 37) {
+ raySize = 3;
+ squareSize = 5;
+ } else {
+ logger.log(Level.SEVERE, "no shape contains {0} pixels", minPixels);
+ return false;
+ }
+
+ int numPixels = squareSize * squareSize + 4 * raySize;
+ assert numPixels >= minPixels : minPixels;
+ int brightness = Math.round(255f * luminosity / numPixels);
+ Color color = new Color(brightness, brightness, brightness);
+ float cornerOffset = 0.5f * (squareSize - 1);
+ int x = Math.round(uv.x * textureSize - cornerOffset);
+ int y = Math.round(uv.y * textureSize - cornerOffset);
+
+ Graphics2D graphics = map.createGraphics();
+ graphics.setColor(color);
+ graphics.fillRect(x, y, squareSize, squareSize);
+ if (raySize == 0) {
+ return true;
+ }
+
+ assert MyMath.isOdd(squareSize) : squareSize;
+ int halfSize = (squareSize - 1) / 2;
+ switch (raySize) {
+ case 1:
+ graphics.fillRect(x - 1, y + halfSize, 1, 1);
+ graphics.fillRect(x + halfSize, y - 1, 1, 1);
+ graphics.fillRect(x + halfSize, y + squareSize, 1, 1);
+ graphics.fillRect(x + squareSize, y + halfSize, 1, 1);
+ break;
+ case 3:
+ graphics.fillRect(x - 1, y + halfSize - 1, 1, 3);
+ graphics.fillRect(x + halfSize - 1, y - 1, 3, 1);
+ graphics.fillRect(x + halfSize - 1, y + squareSize, 3, 1);
+ graphics.fillRect(x + squareSize, y + halfSize - 1, 1, 3);
+ break;
+ default:
+ assert false : raySize;
+ }
+ return true;
+ }
+
+ /**
+ * Draw a dome-corrected ellipse onto a texture map.
+ *
+ * @param map texture map (not null)
+ * @param luminosity star luminosity (>0)
+ * @param textureSize texture size in pixels per side (>2)
+ * @param uv star texture coordinates (not null)
+ */
+ private static void plotEllipseForDome(BufferedImage map, float luminosity,
+ int textureSize, Vector2f uv) {
+ assert map != null;
+ assert luminosity > 0f : luminosity;
+ assert textureSize > 2 : textureSize;
+ assert uv != null;
+
+ Vector2f offset = uv.subtract(Constants.topUV);
+ float topDist = offset.length();
+ float xDir;
+ float yDir;
+ if (topDist > 0f) {
+ xDir = offset.x / topDist;
+ yDir = offset.y / topDist;
+ } else {
+ xDir = 1f;
+ yDir = 0f;
+ }
+ float stretchFactor = 1f
+ + Constants.stretchCoefficient * topDist * topDist;
+ float a = FastMath.sqrt(luminosity * stretchFactor / FastMath.PI);
+ float b = a / stretchFactor;
+
+ for (int i = 0; i < ellipseNumPoints; ++i) {
+ float theta = FastMath.TWO_PI * i / ellipseNumPoints;
+ float da = a * FastMath.cos(theta);
+ float db = b * FastMath.sin(theta);
+ float dx = db * xDir + da * yDir;
+ float dy = db * yDir - da * xDir;
+ ellipseXs[i] = Math.round(uv.x * textureSize + dx);
+ ellipseYs[i] = Math.round(uv.y * textureSize + dy);
+ }
+ Graphics2D graphics = map.createGraphics();
+ graphics.setColor(Color.WHITE);
+ graphics.fillPolygon(ellipseXs, ellipseYs, ellipseNumPoints);
+ }
+
+ /**
+ * Draw a cube-face corrected ellipse onto a texture map.
+ *
+ * @param map texture map (not null)
+ * @param luminosity star luminosity (>0)
+ * @param textureSize texture size in pixels per side (>2)
+ * @param worldDirection star world direction (length=1)
+ * @param faceIndex cube face index (≥0, <6)
+ */
+ private static void plotEllipseForQuad(BufferedImage map, float luminosity,
+ int textureSize, Vector3f worldDirection, int faceIndex) {
+ Vector3f basis1 = worldDirection.clone();
+ Vector3f basis2 = new Vector3f();
+ Vector3f basis3 = new Vector3f();
+ MyVector3f.generateBasis(basis1, basis2, basis3);
+ float numPixels = textureSize * textureSize;
+ float area = luminosity / numPixels;
+ float r = 1.2f * FastMath.sqrt(area);
+
+ Vector3f p = new Vector3f();
+ for (int i = 0; i < ellipseNumPoints; ++i) {
+ float theta = FastMath.TWO_PI * i / ellipseNumPoints;
+ p.scaleAdd(r * FastMath.cos(theta), basis2, basis1);
+ p.scaleAdd(r * FastMath.sin(theta), basis3, p);
+ Vector2f uv = StarMapProjection.cubeUV(p, faceIndex);
+ if (uv == null) {
+ return;
+ }
+ ellipseXs[i] = Math.round(uv.x * textureSize);
+ ellipseYs[i] = Math.round(uv.y * textureSize);
+ }
+ Graphics2D graphics = map.createGraphics();
+ graphics.setColor(Color.WHITE);
+ graphics.fillPolygon(ellipseXs, ellipseYs, ellipseNumPoints);
+ }
+
+ /**
+ * Plot a star onto cube texture maps.
+ *
+ * @param maps texture maps for 6 cube faces (not null, modified)
+ * @param apparentMagnitude star brightness
+ * @param textureSize texture size in pixels per side (>2)
+ * @param worldDirection star world direction (length=1)
+ * @return true if the star was plotted, otherwise false
+ */
+ private static boolean plotOnCube(BufferedImage[] maps,
+ float apparentMagnitude, int textureSize, Vector3f worldDirection) {
+ float resolution = textureSize / 2_048f;
+ float luminosity0 = 100f * resolution * resolution;
+ float luminosity = luminosity0 * 1.5f
+ * FastMath.pow(pogsonsRatio, -apparentMagnitude);
+ if (luminosity < luminosityCutoff) {
+ return false;
+ }
+
+ boolean result = false;
+ for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
+ Vector2f uv = StarMapProjection.cubeUV(worldDirection, faceIndex);
+ if (uv != null) {
+ BufferedImage map = maps[faceIndex];
+ if (luminosity <= 37f) {
+ result |= plot4PointStar(map, luminosity, textureSize, uv);
+ } else {
+ plotEllipseForQuad(map, luminosity, textureSize,
+ worldDirection, faceIndex);
+ result = true;
+ }
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Plot a star onto a dome texture map.
+ *
+ * @param map texture map (not null, modified)
+ * @param apparentMagnitude star brightness
+ * @param textureSize texture size in pixels per side (>2)
+ * @param worldDirection star world direction (length=1)
+ * @return true if the star was plotted, otherwise false
+ */
+ private static boolean plotOnDome(BufferedImage map,
+ float apparentMagnitude, int textureSize, Vector3f worldDirection) {
+ float resolution = textureSize / 2_048f;
+ float luminosity0 = 37f * resolution * resolution;
+ float luminosity
+ = luminosity0 * FastMath.pow(pogsonsRatio, -apparentMagnitude);
+ if (luminosity < luminosityCutoff) {
+ return false;
+ }
+
+ Vector2f uv = domeMesh.directionUV(worldDirection);
+ if (luminosity <= 37f) {
+ return plot4PointStar(map, luminosity, textureSize, uv);
+ }
+ plotEllipseForDome(map, luminosity, textureSize, uv);
+ return true;
+ }
+}
diff --git a/SkyAssets/src/main/java/jme3utilities/sky/textures/StarMapProjection.java b/SkyAssets/src/main/java/jme3utilities/sky/textures/StarMapProjection.java
new file mode 100644
index 0000000..11fa558
--- /dev/null
+++ b/SkyAssets/src/main/java/jme3utilities/sky/textures/StarMapProjection.java
@@ -0,0 +1,107 @@
+/*
+ Copyright (c) 2026, Take Some
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+ * Neither the name of the copyright holder nor the names of its contributors
+ may be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package jme3utilities.sky.textures;
+
+import com.jme3.math.FastMath;
+import com.jme3.math.Quaternion;
+import com.jme3.math.Vector2f;
+import com.jme3.math.Vector3f;
+import jme3utilities.MyAsset;
+import jme3utilities.math.MyQuaternion;
+import jme3utilities.math.MyVector3f;
+
+/**
+ * Coordinate conversion helpers for star-map generation.
+ *
+ * @author Take Some
+ */
+final class StarMapProjection {
+ /**
+ * Hidden constructor.
+ */
+ private StarMapProjection() {
+ // do nothing
+ }
+
+ /**
+ * Calculate texture coordinates for a direction on one cube face.
+ *
+ * @param direction direction from the cube center (length >0,
+ * unaffected)
+ * @param faceIndex cube face index (≥0, <6)
+ * @return new vector, or null when outside the face
+ */
+ static Vector2f cubeUV(Vector3f direction, int faceIndex) {
+ assert direction != null;
+ assert !MyVector3f.isZero(direction);
+ assert faceIndex >= 0 : faceIndex;
+ assert faceIndex < 6 : faceIndex;
+
+ Vector3f faceDir
+ = MyAsset.copyFaceDirection(faceIndex);
+ Vector3f norm = direction.normalize();
+ float dot = faceDir.dot(norm);
+ if (dot < 0.5f) {
+ return null;
+ }
+
+ norm.divideLocal(dot);
+ Vector3f uDir = MyAsset.copyUDirection(faceIndex);
+ Vector3f vDir = MyAsset.copyVDirection(faceIndex);
+ float u = 0.5f * (1f + uDir.dot(norm));
+ float v = 0.5f * (1f + vDir.dot(norm));
+ Vector2f result = new Vector2f(u, v);
+
+ return result;
+ }
+
+ /**
+ * Convert a star's equatorial coordinates to world coordinates.
+ *
+ * @param star star to convert (not null)
+ * @param latitude radians north of the equator (≤Pi/2, ≥-Pi/2)
+ * @param siderealTime radians since sidereal midnight (<2*Pi, ≥0)
+ * @return new unit vector in world coordinates
+ */
+ static Vector3f worldDirection(
+ Star star, float latitude, float siderealTime) {
+ assert star != null;
+ assert latitude >= -FastMath.HALF_PI : latitude;
+ assert latitude <= FastMath.HALF_PI : latitude;
+ assert siderealTime >= 0f : siderealTime;
+ assert siderealTime < FastMath.TWO_PI : siderealTime;
+
+ Vector3f equatorial = star.getEquatorialLocation(siderealTime);
+ float coLatitude = FastMath.HALF_PI - latitude;
+ Quaternion rotation = new Quaternion();
+ rotation.fromAngleNormalAxis(-coLatitude, Vector3f.UNIT_Y);
+ Vector3f rotated = MyQuaternion.rotate(rotation, equatorial, null);
+ assert rotated.isUnitVector() : rotated;
+ Vector3f result = new Vector3f(-rotated.x, rotated.z, rotated.y);
+
+ return result;
+ }
+}
diff --git a/SkyAssets/src/main/java/jme3utilities/sky/textures/StarMapRenderer.java b/SkyAssets/src/main/java/jme3utilities/sky/textures/StarMapRenderer.java
new file mode 100644
index 0000000..27e02c1
--- /dev/null
+++ b/SkyAssets/src/main/java/jme3utilities/sky/textures/StarMapRenderer.java
@@ -0,0 +1,122 @@
+/*
+ Copyright (c) 2026, Take Some
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+ * Neither the name of the copyright holder nor the names of its contributors
+ may be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package jme3utilities.sky.textures;
+
+import com.jme3.math.FastMath;
+import java.awt.image.BufferedImage;
+import java.awt.image.RenderedImage;
+import java.util.Collection;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Renders complete dome and cube star maps from catalog stars.
+ *
+ * @author Take Some
+ */
+final class StarMapRenderer {
+ /** Message logger for this class. */
+ final private static Logger logger
+ = Logger.getLogger(StarMapRenderer.class.getName());
+
+ /**
+ * Hidden constructor.
+ */
+ private StarMapRenderer() {
+ // do nothing
+ }
+
+ /**
+ * Generate 6 starry sky texture maps for a cube.
+ *
+ * @param stars stars to plot (not null)
+ * @param latitude radians north of the equator (≤Pi/2, ≥-Pi/2)
+ * @param siderealTime radians since sidereal midnight (<2*Pi, ≥0)
+ * @param textureSize size of each texture map (pixels per side, >2)
+ * @return new array of 6 images
+ */
+ static RenderedImage[] generateCubeMap(Collection stars,
+ float latitude, float siderealTime, int textureSize) {
+ assert stars != null;
+ assert latitude >= -FastMath.HALF_PI : latitude;
+ assert latitude <= FastMath.HALF_PI : latitude;
+ assert siderealTime >= 0f : siderealTime;
+ assert siderealTime < FastMath.TWO_PI : siderealTime;
+ assert textureSize > 2 : textureSize;
+
+ BufferedImage[] maps = new BufferedImage[6];
+ for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
+ maps[faceIndex] = new BufferedImage(
+ textureSize, textureSize, BufferedImage.TYPE_BYTE_GRAY);
+ }
+
+ int plotCount = 0;
+ for (Star star : stars) {
+ boolean success = StarMapPlotter.plotOnCube(
+ maps, star, latitude, siderealTime, textureSize);
+ if (success) {
+ ++plotCount;
+ }
+ }
+ logger.log(Level.FINE, "plotted {0} stars", plotCount);
+
+ return maps;
+ }
+
+ /**
+ * Generate a starry sky texture map for a dome.
+ *
+ * @param stars stars to plot (not null)
+ * @param latitude radians north of the equator (≤Pi/2, ≥-Pi/2)
+ * @param siderealTime radians since sidereal midnight (<2*Pi, ≥0)
+ * @param textureSize size of the texture map (pixels per side, >2)
+ * @return new image
+ */
+ static RenderedImage generateDomeMap(Collection stars,
+ float latitude, float siderealTime, int textureSize) {
+ assert stars != null;
+ assert latitude >= -FastMath.HALF_PI : latitude;
+ assert latitude <= FastMath.HALF_PI : latitude;
+ assert siderealTime >= 0f : siderealTime;
+ assert siderealTime < FastMath.TWO_PI : siderealTime;
+ assert textureSize > 2 : textureSize;
+
+ BufferedImage map = new BufferedImage(
+ textureSize, textureSize, BufferedImage.TYPE_BYTE_GRAY);
+
+ int plotCount = 0;
+ for (Star star : stars) {
+ boolean success = StarMapPlotter.plotOnDome(
+ map, star, latitude, siderealTime, textureSize);
+ if (success) {
+ ++plotCount;
+ }
+ }
+ logger.log(Level.FINE, "plotted {0} stars", plotCount);
+
+ return map;
+ }
+}
diff --git a/SkyExamples/build.gradle b/SkyExamples/build.gradle
index 81c6499..a32e22d 100644
--- a/SkyExamples/build.gradle
+++ b/SkyExamples/build.gradle
@@ -119,3 +119,7 @@ tasks.register('TestSkyControlWater', JavaExec) {
tasks.register('TestSkyMaterial', JavaExec) {
mainClass = 'jme3utilities.sky.test.TestSkyMaterial'
}
+
+tasks.register('SkyCloudWeatherSmoke', JavaExec) {
+ mainClass = 'jme3utilities.sky.test.SkyCloudWeatherSmoke'
+}
diff --git a/SkyExamples/src/main/java/jme3utilities/sky/test/SkyCloudWeatherSmoke.java b/SkyExamples/src/main/java/jme3utilities/sky/test/SkyCloudWeatherSmoke.java
new file mode 100644
index 0000000..187a51a
--- /dev/null
+++ b/SkyExamples/src/main/java/jme3utilities/sky/test/SkyCloudWeatherSmoke.java
@@ -0,0 +1,143 @@
+/*
+ Copyright (c) 2026, Take Some
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+ * Neither the name of the copyright holder nor the names of its contributors
+ may be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package jme3utilities.sky.test;
+
+import com.jme3.app.SimpleApplication;
+import com.jme3.light.AmbientLight;
+import com.jme3.light.DirectionalLight;
+import com.jme3.math.Vector3f;
+import com.jme3.post.FilterPostProcessor;
+import com.jme3.post.filters.BloomFilter;
+import com.jme3.system.AppSettings;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import jme3utilities.sky.SkyControl;
+import jme3utilities.sky.StarsOption;
+import jme3utilities.sky.Updater;
+import jme3utilities.sky.cloud.SkyCloudPreset;
+
+/**
+ * Visual smoke test for cloud weather presets and generated cloud shaders.
+ *
+ * @author Take Some
+ */
+final class SkyCloudWeatherSmoke extends SimpleApplication {
+ /** Message logger for this class. */
+ final private static Logger logger
+ = Logger.getLogger(SkyCloudWeatherSmoke.class.getName());
+ /** Transition duration between presets. */
+ final private static float transitionSeconds = 1.2f;
+ /** Application time accumulator. */
+ private float elapsed;
+ /** Next transition slot. */
+ private int transitionIndex;
+ /** Sky control under test. */
+ private SkyControl skyControl;
+
+ /**
+ * Main entry point.
+ *
+ * @param arguments ignored command-line arguments
+ */
+ public static void main(String[] arguments) {
+ logger.setLevel(Level.INFO);
+ SkyCloudWeatherSmoke application = new SkyCloudWeatherSmoke();
+ application.setPauseOnLostFocus(false);
+
+ boolean loadDefaults = true;
+ AppSettings settings = new AppSettings(loadDefaults);
+ settings.setTitle("SkyCloudWeatherSmoke");
+ settings.setResolution(960, 540);
+ application.setSettings(settings);
+ application.setShowSettings(false);
+ application.start();
+ }
+
+ /** Initialize the scene. */
+ @Override
+ public void simpleInitApp() {
+ flyCam.setMoveSpeed(25f);
+ cam.setLocation(new Vector3f(0f, 3f, 8f));
+ cam.lookAt(Vector3f.ZERO, Vector3f.UNIT_Y);
+
+ AmbientLight ambientLight = new AmbientLight();
+ DirectionalLight mainLight = new DirectionalLight();
+ rootNode.addLight(ambientLight);
+ rootNode.addLight(mainLight);
+
+ float cloudFlattening = 0.9f;
+ boolean bottomDome = true;
+ this.skyControl = new SkyControl(
+ assetManager, cam, cloudFlattening, StarsOption.TwoDomes,
+ bottomDome);
+ rootNode.addControl(skyControl);
+ skyControl.setEnabled(true);
+ skyControl.setCloudModulation(true);
+ skyControl.getSunAndStars().setHour(14f);
+ skyControl.setCloudPreset(SkyCloudPreset.WISPY, 0f);
+
+ Updater updater = skyControl.getUpdater();
+ updater.addViewPort(viewPort);
+ updater.setAmbientLight(ambientLight);
+ updater.setMainLight(mainLight);
+
+ FilterPostProcessor fpp = new FilterPostProcessor(assetManager);
+ BloomFilter bloom = new BloomFilter(BloomFilter.GlowMode.Objects);
+ bloom.setBloomIntensity(1.5f);
+ fpp.addFilter(bloom);
+ viewPort.addProcessor(fpp);
+ updater.addBloomFilter(bloom);
+
+ logger.info("Started WISPY preset");
+ }
+
+ /**
+ * Update the smoke-test timeline.
+ *
+ * @param tpf time per frame in seconds
+ */
+ @Override
+ public void simpleUpdate(float tpf) {
+ elapsed += tpf;
+ if (transitionIndex == 0 && elapsed >= 2f) {
+ skyControl.setCloudPreset(SkyCloudPreset.RAIN, transitionSeconds);
+ logger.info("Transition to RAIN");
+ ++transitionIndex;
+ } else if (transitionIndex == 1 && elapsed >= 5f) {
+ skyControl.setCloudPreset(SkyCloudPreset.STORM, transitionSeconds);
+ logger.info("Transition to STORM");
+ ++transitionIndex;
+ } else if (transitionIndex == 2 && elapsed >= 8f) {
+ skyControl.setCloudPreset(SkyCloudPreset.WISPY, transitionSeconds);
+ logger.info("Transition back to WISPY");
+ ++transitionIndex;
+ } else if (transitionIndex == 3 && elapsed >= 12f) {
+ logger.info("Cloud weather visual smoke completed.");
+ stop(true);
+ ++transitionIndex;
+ }
+ }
+}
diff --git a/SkyLibrary/build.gradle b/SkyLibrary/build.gradle
index a2a7bbc..33ea89e 100644
--- a/SkyLibrary/build.gradle
+++ b/SkyLibrary/build.gradle
@@ -1,4 +1,4 @@
-// Gradle script to build and publish the SkyLibrary subproject of SkyControl
+// Gradle script to build and publish the SkyLibrary package
// Note: "common.gradle" in the root project contains additional initialization
// for this project. This initialization is applied in the "build.gradle"
@@ -11,19 +11,21 @@ plugins {
}
ext {
- group = 'com.github.stephengold'
- artifact = 'SkyControl'
- version = skycontrolVersion
+ group = rootProject.ext.skySimulationGroup
+ artifact = rootProject.ext.skySimulationArtifact
+ version = rootProject.ext.skySimulationVersion
baseName = "${artifact}-${version}" // for artifacts
- websiteUrl = 'https://github.com/stephengold/SkyControl'
+ websiteUrl = 'https://github.com/Take-Some/SkySimulation'
}
-processResources.dependsOn(':SkyAssets:skyTextures')
+processResources.dependsOn(':SkyAssets:skyTextures', ':SkyAssets:skyMaterials')
dependencies {
api(libs.jme3.effects)
api(libs.heart)
+ implementation(libs.luaj.jse)
+
testImplementation(libs.jme3.desktop)
testImplementation(libs.junit4)
}
@@ -32,11 +34,11 @@ dependencies {
tasks.register('install') {
dependsOn 'publishMavenPublicationToMavenLocal'
- description = 'Installs the Maven artifacts to the local repository.'
+ description = 'Installs the Take Some SkySimulation package to Maven Local.'
}
tasks.register('release') {
dependsOn 'publishMavenPublicationToCentralRepository'
- description = 'Stages the Maven artifacts to the Central Publisher Portal.'
+ description = 'Stages the Maven artifacts to the configured repository.'
}
jar {
@@ -95,12 +97,11 @@ publishing {
from components.java
groupId = project.ext.group
pom {
- description = 'A sky-simulation library for jMonkeyEngine'
+ description = 'Take Some internal sky-simulation library for jMonkeyEngine'
developers {
developer {
- email = 'sgold@sonic.net'
- id = 'stephengold'
- name = 'Stephen Gold'
+ id = 'takesome'
+ name = 'Take Some'
}
}
licenses {
@@ -112,8 +113,8 @@ publishing {
}
name = project.ext.group + ':' + artifact
scm {
- connection = 'scm:git:git://github.com/stephengold/SkyControl.git'
- developerConnection = 'scm:git:ssh://github.com:stephengold/SkyControl.git'
+ connection = 'scm:git:https://github.com/Take-Some/SkySimulation.git'
+ developerConnection = 'scm:git:https://github.com/Take-Some/SkySimulation.git'
url = project.ext.websiteUrl + '/tree/master'
}
url = project.ext.websiteUrl
@@ -134,13 +135,27 @@ publishing {
name = 'Central'
url = 'https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/'
}
+ maven {
+ name = 'GitHubPackages'
+ url = uri('https://maven.pkg.github.com/Take-Some/SkySimulation')
+ credentials {
+ username = findProperty('gpr.user') ?: System.getenv('GITHUB_ACTOR')
+ password = findProperty('gpr.key') ?: System.getenv('GITHUB_TOKEN')
+ }
+ }
}
}
generateMetadataFileForMavenPublication.dependsOn('pom')
publishMavenPublicationToMavenLocal.dependsOn('assemble')
publishMavenPublicationToMavenLocal.doLast {
- println 'installed locally as ' + baseName
+ println 'installed locally as ' + project.ext.group + ':' \
+ + artifact + ':' + project.ext.version
+}
+tasks.register('publishGithubPackage') {
+ dependsOn 'publishMavenPublicationToGitHubPackagesRepository'
+ description = 'Publishes the Take Some SkySimulation package to GitHub Packages.'
}
+publishMavenPublicationToGitHubPackagesRepository.dependsOn('assemble')
publishMavenPublicationToCentralRepository.dependsOn('assemble')
// Register tasks to sign the Maven artifacts for publication:
diff --git a/SkyLibrary/release-notes.md b/SkyLibrary/release-notes.md
index c5f9b39..a51ca96 100644
--- a/SkyLibrary/release-notes.md
+++ b/SkyLibrary/release-notes.md
@@ -1,5 +1,47 @@
# release log for the SkyControl library and related software
+## Version 1.4.3 released on 27 June 2026
+
+Notable changes:
++ extract weather subscription dispatcher from the environment runtime
++ reduce weather event dispatch allocation and active subscription scans
++ preserve weather listener replay, cancellation, and failure isolation behavior
++ update package coordinates and release documentation for version 1.4.3
+
+## Version 1.4.2 released on 27 June 2026
+
+Notable changes:
++ add smooth runtime atmosphere transitions for gradient style, sunset intensity, sun halo, and moon halo controls
++ add optional `transitionSeconds` arguments to atmosphere command ABI entries
++ add typed game-facing weather subscription API with listeners, filters, events, and cancellable subscription handles
++ add built-in filters for weather id, built-in preset, storm-like states, precipitation, cloudiness, wind, and all weather changes
++ add expanded logging for Lua weather ABI loading, cloud preset parsing, cloud transition lifecycle, material generation, and cloud normal-map application
++ add compressed DDS/BC texture diagnostics for dimensions, mip levels, FourCC, image format, and block size
++ add tests for atmosphere transitions and weather subscriptions
+
+## Version 1.4.1 released on 25 June 2026
+
+Notable changes:
++ add atmospheric gradient style presets (`REALISTIC`, `CINEMATIC`, `FANTASY`)
++ add runtime sunset, sun-halo, and moon-halo intensity controls
++ add Lua config ABI fields for atmospheric gradient styling
++ add command ABI entries for live atmosphere gradient changes
++ add SkyCommandBus support for runtime atmosphere commands
++ improve sunrise, sunset, sun glow, and moon glow gradient math
++ harden GitHub CI for push, pull request, and release validation
+
+## Version 1.4.0 released on 25 June 2026
+
+Notable changes:
++ add Lua weather ABI (`helix/lua/sky/weather.lua`)
++ add data-driven weather preset definitions and registry loader
++ add game-facing environment snapshots and world clock
++ add Lua SkySimulation config ABI (`helix/lua/sky/default-sky.lua`)
++ add typed SkySimulation config loading and application
++ add command ABI (`helix/lua/sky/commands.lua`) and command bus
++ add commands for weather, clock, environment snapshot, and config reload
++ run full build and local package validation for releases
+
## Version 1.1.0 released on 18 October 2024
Notable changes:
diff --git a/SkyLibrary/src/main/java/jme3utilities/sky/CloudLayer.java b/SkyLibrary/src/main/java/jme3utilities/sky/CloudLayer.java
index 8eac187..cae586c 100644
--- a/SkyLibrary/src/main/java/jme3utilities/sky/CloudLayer.java
+++ b/SkyLibrary/src/main/java/jme3utilities/sky/CloudLayer.java
@@ -210,12 +210,22 @@ public void setTexture(String assetPath, float scale) {
material.setCloudsScale(layerIndex, scale);
}
+
+ /**
+ * Add, replace, or clear this layer's normal-map texture.
+ *
+ * @param assetPath asset path to the normal map, or null to clear it
+ */
+ public void setNormalMap(String assetPath) {
+ material.setCloudsNormalMap(layerIndex, assetPath);
+ }
+
/**
* Update this layer's texture offset in the material.
*
* @param time (in seconds)
*/
- void updateOffset(float time) {
+ public void updateOffset(float time) {
float u = u0 + time * uRate;
float v = v0 + time * vRate;
material.setCloudsOffset(layerIndex, u, v);
diff --git a/SkyLibrary/src/main/java/jme3utilities/sky/Constants.java b/SkyLibrary/src/main/java/jme3utilities/sky/Constants.java
index d029881..d6e9275 100644
--- a/SkyLibrary/src/main/java/jme3utilities/sky/Constants.java
+++ b/SkyLibrary/src/main/java/jme3utilities/sky/Constants.java
@@ -74,7 +74,7 @@ final public class Constants {
/**
* UV distance from top to rim (<0.5, >0)
*/
- final static float uvScale = 0.44f;
+ final public static float uvScale = 0.44f;
/**
* coefficient used to compute the stretchFactor for objects projected onto
* a DomeMesh
@@ -106,6 +106,6 @@ private Constants() {
* @return branch and revision (not null, not empty)
*/
public static String versionShort() {
- return "master 1.1.1-SNAPSHOT";
+ return "master 1.4.3";
}
}
diff --git a/SkyLibrary/src/main/java/jme3utilities/sky/SkyAtmosphere.java b/SkyLibrary/src/main/java/jme3utilities/sky/SkyAtmosphere.java
new file mode 100644
index 0000000..92ea243
--- /dev/null
+++ b/SkyLibrary/src/main/java/jme3utilities/sky/SkyAtmosphere.java
@@ -0,0 +1,925 @@
+/*
+ Copyright (c) 2026, Take Some
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+ * Neither the name of the copyright holder nor the names of its contributors
+ may be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package jme3utilities.sky;
+
+import com.jme3.export.InputCapsule;
+import com.jme3.export.JmeExporter;
+import com.jme3.export.JmeImporter;
+import com.jme3.export.OutputCapsule;
+import com.jme3.export.Savable;
+import com.jme3.math.ColorRGBA;
+import com.jme3.util.clone.Cloner;
+import com.jme3.util.clone.JmeCloneable;
+import java.io.IOException;
+import java.util.Locale;
+import java.util.Properties;
+import jme3utilities.Validate;
+import jme3utilities.sky.atmosphere.SkyAtmosphereProperties;
+import jme3utilities.sky.atmosphere.SkyGradientStyle;
+
+/**
+ * Mutable atmospheric tuning profile used by SkyControl.
+ *
+ * The profile intentionally remains renderer-independent: it does not replace
+ * the existing dome shaders, but provides physically-inspired controls for
+ * daylight extinction, horizon warming, twilight length, cloud brightness,
+ * moonlight, starlight, ambient balance, bloom, and shadows.
+ *
+ * All color components are linear-space multipliers as expected by jME lights.
+ * Runtime code may either mutate the instance returned by
+ * {@link SkyControl#getAtmosphere()} or install a copied profile by invoking
+ * {@link SkyControl#setAtmosphere(SkyAtmosphere)}.
+ *
+ * @author Take Some
+ */
+public class SkyAtmosphere implements JmeCloneable, Savable {
+ // *************************************************************************
+ // fields
+
+ /**
+ * Strength of solar extinction through low atmosphere.
+ */
+ private float airMassStrength = 0.85f;
+ /**
+ * Multiplier applied to ambient light computed from sky/cloud color.
+ */
+ private float ambientScale = 1f;
+ /**
+ * Multiplier applied to generated bloom intensity.
+ */
+ private float bloomScale = 1f;
+ /**
+ * Height of the sun where horizon color shift fades out.
+ */
+ private float colorShiftAltitude = 0.35f;
+ /**
+ * Daytime cloud brightness multiplier.
+ */
+ private float cloudDayBrightness = 0.95f;
+ /**
+ * Moonlight boost added to nighttime cloud brightness.
+ */
+ private float cloudMoonBoost = 0.6f;
+ /**
+ * Minimum nighttime cloud brightness.
+ */
+ private float cloudNight = 0.22f;
+ /**
+ * Height of the sun where daylight is considered fully established.
+ */
+ private float fullDayAltitude = 0.25f;
+ /**
+ * Strength of low-altitude haze coloration.
+ */
+ private float hazeStrength = 1f;
+ /**
+ * Upper clamp for generated bloom intensity.
+ */
+ private float maxBloomIntensity = 1.7f;
+ /**
+ * Lower clamp for solar transmission at the horizon.
+ */
+ private float minSunTransmission = 0.08f;
+ /**
+ * Multiplier applied to computed shadow intensity.
+ */
+ private float shadowContrast = 1f;
+ /**
+ * Strength of warm color shift near sunrise and sunset.
+ */
+ private float sunsetWarmth = 1f;
+ /**
+ * Art direction style for gradients and halos.
+ */
+ private SkyGradientStyle gradientStyle = SkyGradientStyle.CINEMATIC;
+ /**
+ * Runtime color-shift style scale.
+ */
+ private float colorScale = SkyGradientStyle.CINEMATIC.colorScale();
+ /**
+ * Runtime halo/glow style scale.
+ */
+ private float haloScale = SkyGradientStyle.CINEMATIC.haloScale();
+ /**
+ * Runtime horizon gradient style scale.
+ */
+ private float horizonScale = SkyGradientStyle.CINEMATIC.horizonScale();
+ /**
+ * Moon halo intensity multiplier.
+ */
+ private float moonHaloIntensity = 1f;
+ /**
+ * Sun halo intensity multiplier.
+ */
+ private float sunHaloIntensity = 1f;
+ /**
+ * Sunset gradient intensity multiplier.
+ */
+ private float sunsetIntensity = 1f;
+ /**
+ * Twilight reach expressed as sine of solar depression below horizon.
+ */
+ private float twilightLimit = 0.12f;
+ /**
+ * Full-moon light color.
+ */
+ private ColorRGBA moonLight
+ = new ColorRGBA(0.25f, 0.28f, 0.42f, Constants.alphaMax);
+ /**
+ * Moonless-night light color.
+ */
+ private ColorRGBA starLight
+ = new ColorRGBA(0.015f, 0.018f, 0.025f, Constants.alphaMax);
+ /**
+ * Full sunlight color before atmospheric extinction.
+ */
+ private ColorRGBA sunLight
+ = new ColorRGBA(0.95f, 0.92f, 0.82f, Constants.alphaMax);
+ /**
+ * Horizon color around sunrise and sunset.
+ */
+ private ColorRGBA twilightColor
+ = new ColorRGBA(0.95f, 0.36f, 0.12f, Constants.alphaMax);
+ // *************************************************************************
+ // constructors
+
+ /**
+ * Instantiate an Earth-like atmospheric profile.
+ */
+ public SkyAtmosphere() {
+ // do nothing
+ }
+ // *************************************************************************
+ // new methods exposed
+
+ /**
+ * Apply overrides from Java properties.
+ *
+ * Recognized color keys use comma-separated components: r,g,b or r,g,b,a.
+ * Recognized keys are: airMassStrength, ambientScale, bloomScale,
+ * colorShiftAltitude, cloudDayBrightness, cloudMoonBoost,
+ * cloudNight, fullDayAltitude, gradientStyle, hazeStrength,
+ * maxBloomIntensity, minSunTransmission, moonHaloIntensity,
+ * shadowContrast, sunHaloIntensity, sunsetIntensity, sunsetWarmth,
+ * twilightLimit, moonLight, starLight, sunLight, and twilightColor.
+ *
+ * @param properties source properties (not null)
+ */
+ public void apply(Properties properties) {
+ Validate.nonNull(properties, "properties");
+
+ setAirMassStrength(SkyAtmosphereProperties.readFloat(properties,
+ "airMassStrength", airMassStrength));
+ setAmbientScale(SkyAtmosphereProperties.readFloat(
+ properties, "ambientScale", ambientScale));
+ setBloomScale(SkyAtmosphereProperties.readFloat(
+ properties, "bloomScale", bloomScale));
+ setColorShiftAltitude(SkyAtmosphereProperties.readFloat(properties,
+ "colorShiftAltitude", colorShiftAltitude));
+ setCloudDayBrightness(SkyAtmosphereProperties.readFloat(properties,
+ "cloudDayBrightness", cloudDayBrightness));
+ setCloudMoonBoost(SkyAtmosphereProperties.readFloat(properties,
+ "cloudMoonBoost", cloudMoonBoost));
+ setCloudNight(SkyAtmosphereProperties.readFloat(properties,
+ "cloudNight", cloudNight));
+ setFullDayAltitude(SkyAtmosphereProperties.readFloat(properties,
+ "fullDayAltitude", fullDayAltitude));
+ setHazeStrength(SkyAtmosphereProperties.readFloat(
+ properties, "hazeStrength", hazeStrength));
+ setMaxBloomIntensity(SkyAtmosphereProperties.readFloat(properties,
+ "maxBloomIntensity", maxBloomIntensity));
+ setMinSunTransmit(SkyAtmosphereProperties.readFloat(properties,
+ "minSunTransmission", minSunTransmission));
+ setShadowContrast(SkyAtmosphereProperties.readFloat(properties,
+ "shadowContrast", shadowContrast));
+ setGradientStyle(readGradientStyle(properties));
+ setMoonHaloIntensity(SkyAtmosphereProperties.readFloat(
+ properties, "moonHaloIntensity", moonHaloIntensity));
+ setSunHaloIntensity(SkyAtmosphereProperties.readFloat(
+ properties, "sunHaloIntensity", sunHaloIntensity));
+ setSunsetIntensity(SkyAtmosphereProperties.readFloat(
+ properties, "sunsetIntensity", sunsetIntensity));
+ setSunsetWarmth(SkyAtmosphereProperties.readFloat(
+ properties, "sunsetWarmth", sunsetWarmth));
+ setTwilightLimit(SkyAtmosphereProperties.readFloat(
+ properties, "twilightLimit", twilightLimit));
+
+ setMoonLight(SkyAtmosphereProperties.readColor(
+ properties, "moonLight", moonLight));
+ setStarLight(SkyAtmosphereProperties.readColor(
+ properties, "starLight", starLight));
+ setSunLight(SkyAtmosphereProperties.readColor(
+ properties, "sunLight", sunLight));
+ setTwilightColor(SkyAtmosphereProperties.readColor(
+ properties, "twilightColor", twilightColor));
+ }
+
+ /**
+ * Copy this profile.
+ *
+ * @return a new profile equivalent to this one
+ */
+ public SkyAtmosphere copy() {
+ SkyAtmosphere result = new SkyAtmosphere();
+ result.copyFrom(this);
+ return result;
+ }
+
+ /**
+ * Copy the moonlight color.
+ *
+ * @param storeResult storage for the result (modified if not null)
+ * @return copied color
+ */
+ public ColorRGBA copyMoonLight(ColorRGBA storeResult) {
+ ColorRGBA result = copyColor(moonLight, storeResult);
+ return result;
+ }
+
+ /**
+ * Copy the starlight color.
+ *
+ * @param storeResult storage for the result (modified if not null)
+ * @return copied color
+ */
+ public ColorRGBA copyStarLight(ColorRGBA storeResult) {
+ ColorRGBA result = copyColor(starLight, storeResult);
+ return result;
+ }
+
+ /**
+ * Copy the sunlight color.
+ *
+ * @param storeResult storage for the result (modified if not null)
+ * @return copied color
+ */
+ public ColorRGBA copySunLight(ColorRGBA storeResult) {
+ ColorRGBA result = copyColor(sunLight, storeResult);
+ return result;
+ }
+
+ /**
+ * Copy the twilight color.
+ *
+ * @param storeResult storage for the result (modified if not null)
+ * @return copied color
+ */
+ public ColorRGBA copyTwilightColor(ColorRGBA storeResult) {
+ ColorRGBA result = copyColor(twilightColor, storeResult);
+ return result;
+ }
+
+ /**
+ * Copy all tunables from another profile.
+ *
+ * @param source source profile (not null, unaffected)
+ */
+ final public void copyFrom(SkyAtmosphere source) {
+ Validate.nonNull(source, "source");
+
+ this.airMassStrength = source.airMassStrength;
+ this.ambientScale = source.ambientScale;
+ this.bloomScale = source.bloomScale;
+ this.colorShiftAltitude = source.colorShiftAltitude;
+ this.cloudDayBrightness = source.cloudDayBrightness;
+ this.cloudMoonBoost = source.cloudMoonBoost;
+ this.cloudNight = source.cloudNight;
+ this.fullDayAltitude = source.fullDayAltitude;
+ this.hazeStrength = source.hazeStrength;
+ this.maxBloomIntensity = source.maxBloomIntensity;
+ this.minSunTransmission = source.minSunTransmission;
+ this.shadowContrast = source.shadowContrast;
+ this.gradientStyle = source.gradientStyle;
+ this.colorScale = source.colorScale;
+ this.haloScale = source.haloScale;
+ this.horizonScale = source.horizonScale;
+ this.moonHaloIntensity = source.moonHaloIntensity;
+ this.sunHaloIntensity = source.sunHaloIntensity;
+ this.sunsetIntensity = source.sunsetIntensity;
+ this.sunsetWarmth = source.sunsetWarmth;
+ this.twilightLimit = source.twilightLimit;
+ this.moonLight = source.moonLight.clone();
+ this.starLight = source.starLight.clone();
+ this.sunLight = source.sunLight.clone();
+ this.twilightColor = source.twilightColor.clone();
+ }
+
+ /**
+ * Return the strength of solar extinction through low atmosphere.
+ *
+ * @return extinction strength (≥0)
+ */
+ public float getAirMassStrength() {
+ return airMassStrength;
+ }
+
+ /**
+ * Return the ambient light scale.
+ *
+ * @return multiplier (≥0)
+ */
+ public float getAmbientScale() {
+ return ambientScale;
+ }
+
+ /**
+ * Return the bloom scale.
+ *
+ * @return multiplier (≥0)
+ */
+ public float getBloomScale() {
+ return bloomScale;
+ }
+
+ /**
+ * Return the altitude where warm horizon shift fades out.
+ *
+ * @return sine of solar altitude (>0, ≤1)
+ */
+ public float getColorShiftAltitude() {
+ return colorShiftAltitude;
+ }
+
+ /**
+ * Return the daytime cloud brightness multiplier.
+ *
+ * @return multiplier (≥0)
+ */
+ public float getCloudDayBrightness() {
+ return cloudDayBrightness;
+ }
+
+ /**
+ * Return the moonlight boost applied to clouds at night.
+ *
+ * @return multiplier (≥0)
+ */
+ public float getCloudMoonBoost() {
+ return cloudMoonBoost;
+ }
+
+ /**
+ * Return the minimum cloud brightness at night.
+ *
+ * @return multiplier (≥0)
+ */
+ public float getCloudNight() {
+ return cloudNight;
+ }
+
+ /**
+ * Return the altitude where daylight is fully established.
+ *
+ * @return sine of solar altitude (>0, ≤1)
+ */
+ public float getFullDayAltitude() {
+ return fullDayAltitude;
+ }
+
+ /**
+ * Return the runtime color-shift style scale.
+ *
+ * @return multiplier
+ */
+ public float getColorScale() {
+ return colorScale;
+ }
+
+ /**
+ * Return the gradient art-direction style.
+ *
+ * @return gradient style
+ */
+ public SkyGradientStyle getGradientStyle() {
+ return gradientStyle;
+ }
+
+ /**
+ * Return the runtime halo/glow style scale.
+ *
+ * @return multiplier
+ */
+ public float getHaloScale() {
+ return haloScale;
+ }
+
+ /**
+ * Return the runtime horizon gradient style scale.
+ *
+ * @return multiplier
+ */
+ public float getHorizonScale() {
+ return horizonScale;
+ }
+
+ /**
+ * Return the strength of low-altitude haze coloration.
+ *
+ * @return fraction (≤1, ≥0)
+ */
+ public float getHazeStrength() {
+ return hazeStrength;
+ }
+
+ /**
+ * Return the moon halo intensity multiplier.
+ *
+ * @return multiplier (≥0)
+ */
+ public float getMoonHaloIntensity() {
+ return moonHaloIntensity;
+ }
+
+ /**
+ * Return the bloom intensity clamp.
+ *
+ * @return intensity (≥0)
+ */
+ public float getMaxBloomIntensity() {
+ return maxBloomIntensity;
+ }
+
+ /**
+ * Return the lower clamp for solar transmission.
+ *
+ * @return fraction (≤1, ≥0)
+ */
+ public float getMinSunTransmit() {
+ return minSunTransmission;
+ }
+
+ /**
+ * Return the shadow contrast multiplier.
+ *
+ * @return multiplier (≥0)
+ */
+ public float getShadowContrast() {
+ return shadowContrast;
+ }
+
+ /**
+ * Return the sun halo intensity multiplier.
+ *
+ * @return multiplier (≥0)
+ */
+ public float getSunHaloIntensity() {
+ return sunHaloIntensity;
+ }
+
+ /**
+ * Return the sunset gradient intensity multiplier.
+ *
+ * @return multiplier (≥0)
+ */
+ public float getSunsetIntensity() {
+ return sunsetIntensity;
+ }
+
+ /**
+ * Return the strength of warm sunrise/sunset coloration.
+ *
+ * @return fraction (≤1, ≥0)
+ */
+ public float getSunsetWarmth() {
+ return sunsetWarmth;
+ }
+
+ /**
+ * Return twilight reach below the horizon.
+ *
+ * @return sine of solar depression (>0, ≤1)
+ */
+ public float getTwilightLimit() {
+ return twilightLimit;
+ }
+
+ /**
+ * Alter the strength of solar extinction through low atmosphere.
+ *
+ * @param strength desired extinction strength (≥0)
+ */
+ public void setAirMassStrength(float strength) {
+ Validate.nonNegative(strength, "strength");
+ this.airMassStrength = strength;
+ }
+
+ /**
+ * Alter the ambient light scale.
+ *
+ * @param scale desired multiplier (≥0)
+ */
+ public void setAmbientScale(float scale) {
+ Validate.nonNegative(scale, "scale");
+ this.ambientScale = scale;
+ }
+
+ /**
+ * Alter the bloom scale.
+ *
+ * @param scale desired multiplier (≥0)
+ */
+ public void setBloomScale(float scale) {
+ Validate.nonNegative(scale, "scale");
+ this.bloomScale = scale;
+ }
+
+ /**
+ * Alter the altitude where warm horizon shift fades out.
+ *
+ * @param altitude sine of solar altitude (>0, ≤1)
+ */
+ public void setColorShiftAltitude(float altitude) {
+ SkyAtmosphereProperties.validatePosFraction(altitude, "altitude");
+ this.colorShiftAltitude = altitude;
+ }
+
+ /**
+ * Alter the daytime cloud brightness multiplier.
+ *
+ * @param brightness desired multiplier (≥0)
+ */
+ public void setCloudDayBrightness(float brightness) {
+ Validate.nonNegative(brightness, "brightness");
+ this.cloudDayBrightness = brightness;
+ }
+
+ /**
+ * Alter the moonlight boost applied to clouds at night.
+ *
+ * @param boost desired multiplier (≥0)
+ */
+ public void setCloudMoonBoost(float boost) {
+ Validate.nonNegative(boost, "boost");
+ this.cloudMoonBoost = boost;
+ }
+
+ /**
+ * Alter the minimum cloud brightness at night.
+ *
+ * @param brightness desired multiplier (≥0)
+ */
+ public void setCloudNight(float brightness) {
+ Validate.nonNegative(brightness, "brightness");
+ this.cloudNight = brightness;
+ }
+
+ /**
+ * Alter the altitude where daylight is fully established.
+ *
+ * @param altitude sine of solar altitude (>0, ≤1)
+ */
+ public void setFullDayAltitude(float altitude) {
+ SkyAtmosphereProperties.validatePosFraction(altitude, "altitude");
+ this.fullDayAltitude = altitude;
+ }
+
+ /**
+ * Alter the gradient art-direction style.
+ *
+ * @param style desired style (not null)
+ */
+ public void setGradientStyle(SkyGradientStyle style) {
+ Validate.nonNull(style, "style");
+ this.gradientStyle = style;
+ setGradientScales(style.horizonScale(), style.colorScale(),
+ style.haloScale());
+ }
+
+ /**
+ * Alter runtime gradient style scales.
+ *
+ * @param horizon desired horizon scale (≥0)
+ * @param color desired color scale (≥0)
+ * @param halo desired halo scale (≥0)
+ */
+ public void setGradientScales(float horizon, float color, float halo) {
+ Validate.nonNegative(horizon, "horizon");
+ Validate.nonNegative(color, "color");
+ Validate.nonNegative(halo, "halo");
+
+ this.horizonScale = horizon;
+ this.colorScale = color;
+ this.haloScale = halo;
+ }
+
+ /**
+ * Alter the strength of low-altitude haze coloration.
+ *
+ * @param strength desired fraction (≤1, ≥0)
+ */
+ public void setHazeStrength(float strength) {
+ Validate.fraction(strength, "strength");
+ this.hazeStrength = strength;
+ }
+
+ /**
+ * Alter the moon halo intensity multiplier.
+ *
+ * @param intensity desired multiplier (≥0)
+ */
+ public void setMoonHaloIntensity(float intensity) {
+ Validate.nonNegative(intensity, "intensity");
+ this.moonHaloIntensity = intensity;
+ }
+
+ /**
+ * Alter the bloom intensity clamp.
+ *
+ * @param intensity desired intensity (≥0)
+ */
+ public void setMaxBloomIntensity(float intensity) {
+ Validate.nonNegative(intensity, "intensity");
+ this.maxBloomIntensity = intensity;
+ }
+
+ /**
+ * Alter the lower clamp for solar transmission.
+ *
+ * @param fraction desired fraction (≤1, ≥0)
+ */
+ public void setMinSunTransmit(float fraction) {
+ Validate.fraction(fraction, "fraction");
+ this.minSunTransmission = fraction;
+ }
+
+ /**
+ * Alter the full-moon light color.
+ *
+ * @param color desired color (not null, unaffected)
+ */
+ public void setMoonLight(ColorRGBA color) {
+ Validate.nonNull(color, "color");
+ this.moonLight = color.clone();
+ }
+
+ /**
+ * Alter the shadow contrast multiplier.
+ *
+ * @param contrast desired multiplier (≥0)
+ */
+ public void setShadowContrast(float contrast) {
+ Validate.nonNegative(contrast, "contrast");
+ this.shadowContrast = contrast;
+ }
+
+ /**
+ * Alter the moonless-night light color.
+ *
+ * @param color desired color (not null, unaffected)
+ */
+ public void setStarLight(ColorRGBA color) {
+ Validate.nonNull(color, "color");
+ this.starLight = color.clone();
+ }
+
+ /**
+ * Alter the sun halo intensity multiplier.
+ *
+ * @param intensity desired multiplier (≥0)
+ */
+ public void setSunHaloIntensity(float intensity) {
+ Validate.nonNegative(intensity, "intensity");
+ this.sunHaloIntensity = intensity;
+ }
+
+ /**
+ * Alter full sunlight color before atmospheric extinction.
+ *
+ * @param color desired color (not null, unaffected)
+ */
+ public void setSunLight(ColorRGBA color) {
+ Validate.nonNull(color, "color");
+ this.sunLight = color.clone();
+ }
+
+ /**
+ * Alter sunset gradient intensity.
+ *
+ * @param intensity desired multiplier (≥0)
+ */
+ public void setSunsetIntensity(float intensity) {
+ Validate.nonNegative(intensity, "intensity");
+ this.sunsetIntensity = intensity;
+ }
+
+ /**
+ * Alter the strength of warm sunrise/sunset coloration.
+ *
+ * @param strength desired fraction (≤1, ≥0)
+ */
+ public void setSunsetWarmth(float strength) {
+ Validate.fraction(strength, "strength");
+ this.sunsetWarmth = strength;
+ }
+
+ /**
+ * Alter twilight reach below the horizon.
+ *
+ * @param limit sine of solar depression (>0, ≤1)
+ */
+ public void setTwilightLimit(float limit) {
+ SkyAtmosphereProperties.validatePosFraction(limit, "limit");
+ this.twilightLimit = limit;
+ }
+
+ /**
+ * Alter horizon color around sunrise and sunset.
+ *
+ * @param color desired color (not null, unaffected)
+ */
+ public void setTwilightColor(ColorRGBA color) {
+ Validate.nonNull(color, "color");
+ this.twilightColor = color.clone();
+ }
+ // *************************************************************************
+ // JmeCloneable methods
+
+ /**
+ * Convert this shallow-cloned profile into a deep-cloned one.
+ *
+ * @param cloner active cloner
+ * @param original the profile from which this profile was shallow-cloned
+ */
+ @Override
+ public void cloneFields(Cloner cloner, Object original) {
+ this.moonLight = cloner.clone(moonLight);
+ this.starLight = cloner.clone(starLight);
+ this.sunLight = cloner.clone(sunLight);
+ this.twilightColor = cloner.clone(twilightColor);
+ }
+
+ /**
+ * Create a shallow clone for the JME cloner.
+ *
+ * @return a new instance (not null)
+ */
+ @Override
+ public SkyAtmosphere jmeClone() {
+ try {
+ SkyAtmosphere clone = (SkyAtmosphere) clone();
+ return clone;
+ } catch (CloneNotSupportedException exception) {
+ throw new RuntimeException(exception);
+ }
+ }
+ // *************************************************************************
+ // Object methods
+
+ /**
+ * Clone this profile.
+ *
+ * @return new profile equivalent to this one
+ * @throws CloneNotSupportedException from Object.clone()
+ */
+ @Override
+ public SkyAtmosphere clone() throws CloneNotSupportedException {
+ SkyAtmosphere clone = (SkyAtmosphere) super.clone();
+ clone.moonLight = moonLight.clone();
+ clone.starLight = starLight.clone();
+ clone.sunLight = sunLight.clone();
+ clone.twilightColor = twilightColor.clone();
+ return clone;
+ }
+ // *************************************************************************
+ // Savable methods
+
+ /**
+ * De-serialize this profile, for example when loading from a J3O file.
+ *
+ * @param importer (not null)
+ * @throws IOException from importer
+ */
+ @Override
+ public void read(JmeImporter importer) throws IOException {
+ InputCapsule capsule = importer.getCapsule(this);
+
+ airMassStrength = capsule.readFloat("airMassStrength", 0.85f);
+ ambientScale = capsule.readFloat("ambientScale", 1f);
+ bloomScale = capsule.readFloat("bloomScale", 1f);
+ colorShiftAltitude = capsule.readFloat("colorShiftAltitude", 0.35f);
+ cloudDayBrightness = capsule.readFloat("cloudDayBrightness", 0.95f);
+ cloudMoonBoost = capsule.readFloat("cloudMoonBoost", 0.6f);
+ cloudNight = capsule.readFloat("cloudNight", 0.22f);
+ fullDayAltitude = capsule.readFloat("fullDayAltitude", 0.25f);
+ hazeStrength = capsule.readFloat("hazeStrength", 1f);
+ maxBloomIntensity = capsule.readFloat("maxBloomIntensity", 1.7f);
+ minSunTransmission
+ = capsule.readFloat("minSunTransmission", 0.08f);
+ shadowContrast = capsule.readFloat("shadowContrast", 1f);
+ SkyGradientStyle style = capsule.readEnum("gradientStyle",
+ SkyGradientStyle.class, SkyGradientStyle.CINEMATIC);
+ setGradientStyle(style);
+ moonHaloIntensity = capsule.readFloat("moonHaloIntensity", 1f);
+ sunHaloIntensity = capsule.readFloat("sunHaloIntensity", 1f);
+ sunsetIntensity = capsule.readFloat("sunsetIntensity", 1f);
+ sunsetWarmth = capsule.readFloat("sunsetWarmth", 1f);
+ twilightLimit = capsule.readFloat("twilightLimit", 0.12f);
+ moonLight = (ColorRGBA) capsule.readSavable(
+ "moonLight",
+ new ColorRGBA(0.25f, 0.28f, 0.42f, Constants.alphaMax));
+ starLight = (ColorRGBA) capsule.readSavable(
+ "starLight",
+ new ColorRGBA(0.015f, 0.018f, 0.025f, Constants.alphaMax));
+ sunLight = (ColorRGBA) capsule.readSavable(
+ "sunLight",
+ new ColorRGBA(0.95f, 0.92f, 0.82f, Constants.alphaMax));
+ twilightColor = (ColorRGBA) capsule.readSavable(
+ "twilightColor",
+ new ColorRGBA(0.95f, 0.36f, 0.12f, Constants.alphaMax));
+ }
+
+ /**
+ * Serialize this profile, for example when saving to a J3O file.
+ *
+ * @param exporter (not null)
+ * @throws IOException from exporter
+ */
+ @Override
+ public void write(JmeExporter exporter) throws IOException {
+ OutputCapsule capsule = exporter.getCapsule(this);
+
+ capsule.write(airMassStrength, "airMassStrength", 0.85f);
+ capsule.write(ambientScale, "ambientScale", 1f);
+ capsule.write(bloomScale, "bloomScale", 1f);
+ capsule.write(colorShiftAltitude, "colorShiftAltitude", 0.35f);
+ capsule.write(cloudDayBrightness, "cloudDayBrightness", 0.95f);
+ capsule.write(cloudMoonBoost, "cloudMoonBoost", 0.6f);
+ capsule.write(cloudNight, "cloudNight", 0.22f);
+ capsule.write(fullDayAltitude, "fullDayAltitude", 0.25f);
+ capsule.write(hazeStrength, "hazeStrength", 1f);
+ capsule.write(maxBloomIntensity, "maxBloomIntensity", 1.7f);
+ capsule.write(minSunTransmission, "minSunTransmission", 0.08f);
+ capsule.write(shadowContrast, "shadowContrast", 1f);
+ capsule.write(gradientStyle, "gradientStyle",
+ SkyGradientStyle.CINEMATIC);
+ capsule.write(moonHaloIntensity, "moonHaloIntensity", 1f);
+ capsule.write(sunHaloIntensity, "sunHaloIntensity", 1f);
+ capsule.write(sunsetIntensity, "sunsetIntensity", 1f);
+ capsule.write(sunsetWarmth, "sunsetWarmth", 1f);
+ capsule.write(twilightLimit, "twilightLimit", 0.12f);
+ capsule.write(moonLight, "moonLight", null);
+ capsule.write(starLight, "starLight", null);
+ capsule.write(sunLight, "sunLight", null);
+ capsule.write(twilightColor, "twilightColor", null);
+ }
+ // *************************************************************************
+ // private methods
+
+ /**
+ * Read gradient style from properties.
+ *
+ * @param properties source properties (not null)
+ * @return parsed or current style
+ */
+ private SkyGradientStyle readGradientStyle(Properties properties) {
+ String text = properties.getProperty("gradientStyle");
+ SkyGradientStyle result;
+ if (text == null) {
+ result = gradientStyle;
+ } else {
+ String key = text.trim().toUpperCase(Locale.ROOT);
+ result = SkyGradientStyle.valueOf(key);
+ }
+ return result;
+ }
+
+ /**
+ * Copy a color.
+ *
+ * @param source source color (not null)
+ * @param storeResult storage for the result (modified if not null)
+ * @return copied color
+ */
+ private static ColorRGBA copyColor(
+ ColorRGBA source, ColorRGBA storeResult) {
+ ColorRGBA result = (storeResult == null)
+ ? new ColorRGBA() : storeResult;
+ result.set(source);
+ return result;
+ }
+
+
+}
diff --git a/SkyLibrary/src/main/java/jme3utilities/sky/SkyCloudLayerFactory.java b/SkyLibrary/src/main/java/jme3utilities/sky/SkyCloudLayerFactory.java
new file mode 100644
index 0000000..afd0f9e
--- /dev/null
+++ b/SkyLibrary/src/main/java/jme3utilities/sky/SkyCloudLayerFactory.java
@@ -0,0 +1,55 @@
+/*
+ Copyright (c) 2026, Take Some
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+ * Neither the name of the copyright holder nor the names of its contributors
+ may be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package jme3utilities.sky;
+
+/**
+ * Factory for cloud-layer runtime objects.
+ *
+ * @author Take Some
+ */
+final class SkyCloudLayerFactory {
+ /**
+ * Hidden constructor.
+ */
+ private SkyCloudLayerFactory() {
+ // do nothing
+ }
+
+ /**
+ * Create cloud-layer runtime objects.
+ *
+ * @param material cloud material (not null)
+ * @return new cloud-layer array
+ */
+ static CloudLayer[] createLayers(SkyMaterial material) {
+ CloudLayer[] result = new CloudLayer[SkyControlCore.numCloudLayers];
+ for (int layer = 0; layer < SkyControlCore.numCloudLayers; ++layer) {
+ result[layer] = new CloudLayer(material, layer);
+ }
+
+ return result;
+ }
+}
diff --git a/SkyLibrary/src/main/java/jme3utilities/sky/SkyControl.java b/SkyLibrary/src/main/java/jme3utilities/sky/SkyControl.java
index c4263b2..c6a98d3 100644
--- a/SkyLibrary/src/main/java/jme3utilities/sky/SkyControl.java
+++ b/SkyLibrary/src/main/java/jme3utilities/sky/SkyControl.java
@@ -26,7 +26,6 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
package jme3utilities.sky;
import com.jme3.asset.AssetManager;
-import com.jme3.asset.AssetNotFoundException;
import com.jme3.export.InputCapsule;
import com.jme3.export.JmeExporter;
import com.jme3.export.JmeImporter;
@@ -37,7 +36,6 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
-import com.jme3.scene.Geometry;
import com.jme3.scene.Node;
import com.jme3.texture.Texture;
import com.jme3.util.clone.Cloner;
@@ -46,8 +44,21 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
import java.util.logging.Logger;
import jme3utilities.Validate;
import jme3utilities.math.MyColor;
-import jme3utilities.math.MyMath;
import jme3utilities.mesh.DomeMesh;
+import jme3utilities.sky.atmosphere.SkyAtmosphereTransitionRuntime;
+import jme3utilities.sky.atmosphere.SkyGradientStyle;
+import jme3utilities.sky.cloud.SkyCloudPreset;
+import jme3utilities.sky.cloud.SkyCloudPresetDefinition;
+import jme3utilities.sky.control.SkyBaseColorRuntime;
+import jme3utilities.sky.control.SkyCloudTransmissionRuntime;
+import jme3utilities.sky.control.SkyLightSelectionRuntime;
+import jme3utilities.sky.control.SkyLightingOutputRuntime;
+import jme3utilities.sky.control.SkyMoonRuntime;
+import jme3utilities.sky.control.SkyObjectLightingRuntime;
+import jme3utilities.sky.runtime.SkyEnvironmentRuntime;
+import jme3utilities.sky.runtime.SkyLightingSnapshot;
+import jme3utilities.sky.runtime.SkyLightingState;
+import jme3utilities.sky.runtime.SkyWorldClock;
/**
* Simple control to simulate a dynamic sky using assets and techniques derived
@@ -84,31 +95,6 @@ public class SkyControl extends SkyControlCore {
// *************************************************************************
// constants and loggers
- /**
- * light color and intensity for full moonlight: bluish gray
- */
- final private static ColorRGBA moonLight
- = new ColorRGBA(0.4f, 0.4f, 0.6f, Constants.alphaMax);
- /**
- * light color and intensity for moonless night: nearly black
- */
- final private static ColorRGBA starLight
- = new ColorRGBA(0.03f, 0.03f, 0.03f, Constants.alphaMax);
- /**
- * light color and intensity for full sunlight: yellowish white
- */
- final private static ColorRGBA sunLight
- = new ColorRGBA(0.8f, 0.8f, 0.75f, Constants.alphaMax);
- /**
- * color blended in around sunrise and sunset: ruddy orange
- */
- final private static ColorRGBA twilight
- = new ColorRGBA(0.6f, 0.3f, 0.15f, Constants.alphaMax);
- /**
- * extent of the twilight periods before sunrise and after sunset, expressed
- * as the sine of the sun's angle below the horizon (≤1, ≥0)
- */
- final private static float limitOfTwilight = 0.1f;
/**
* object index for the moon
*/
@@ -169,6 +155,22 @@ public class SkyControl extends SkyControlCore {
* lights, shadows, and viewports to update
*/
private Updater updater = null;
+ /**
+ * Game-facing environment runtime.
+ */
+ private SkyEnvironmentRuntime environmentRuntime = null;
+ /** atmospheric lighting and realism profile */
+ private SkyAtmosphere atmosphere = new SkyAtmosphere();
+ /** Smooth runtime atmosphere gradient transition. */
+ private SkyAtmosphereTransitionRuntime atmoTransition
+ = new SkyAtmosphereTransitionRuntime();
+ /** custom moon texture asset path, or null to use the phase preset */
+ private String moonAssetPath = null;
+ /** custom moon texture object, or null for phase preset or path */
+ private Texture moonColorMap = null;
+ /** Latest lighting snapshot exposed to game/runtime code. */
+ private SkyLightingSnapshot lastLightSnapshot
+ = SkyLightingSnapshot.empty();
// *************************************************************************
// constructors
@@ -177,6 +179,7 @@ public class SkyControl extends SkyControlCore {
*/
protected SkyControl() {
super();
+ this.environmentRuntime = createEnvRuntime();
}
/**
@@ -202,6 +205,7 @@ public SkyControl(
this.sunAndStars = new SunAndStars();
this.updater = new Updater();
+ this.environmentRuntime = createEnvRuntime();
setPhase(phase);
setSunStyle("Textures/skies/suns/hazy-disc.png");
@@ -262,6 +266,90 @@ public Updater getUpdater() {
return updater;
}
+ /**
+ * Access the game-facing sky environment runtime.
+ *
+ * @return pre-existing runtime
+ */
+ public SkyEnvironmentRuntime environment() {
+ ensureEnvRuntime();
+ return environmentRuntime;
+ }
+
+ /**
+ * Access the atmospheric tuning profile.
+ *
+ * @return the pre-existing mutable profile (not null)
+ */
+ public SkyAtmosphere getAtmosphere() {
+ assert atmosphere != null;
+ return atmosphere;
+ }
+
+ /**
+ * Replace the atmospheric tuning profile.
+ *
+ * The specified profile is copied, so later caller-side mutation will not
+ * alter this control.
+ *
+ * @param newAtmosphere desired profile (not null, unaffected)
+ */
+ public void setAtmosphere(SkyAtmosphere newAtmosphere) {
+ Validate.nonNull(newAtmosphere, "atmosphere");
+ this.atmosphere = newAtmosphere.copy();
+ this.atmoTransition = new SkyAtmosphereTransitionRuntime();
+ }
+
+ /**
+ * Transition the atmosphere gradient style.
+ *
+ * @param style target style (not null)
+ * @param seconds transition duration in seconds (≥0)
+ */
+ public void setGradientStyle(SkyGradientStyle style, float seconds) {
+ atmoTransition.transitionStyle(atmosphere, style, seconds);
+ }
+
+ /**
+ * Transition moon halo intensity.
+ *
+ * @param intensity target intensity (≥0)
+ * @param seconds transition duration in seconds (≥0)
+ */
+ public void setMoonHaloIntensity(float intensity, float seconds) {
+ atmoTransition.transitionMoon(atmosphere, intensity, seconds);
+ }
+
+ /**
+ * Transition sun halo intensity.
+ *
+ * @param intensity target intensity (≥0)
+ * @param seconds transition duration in seconds (≥0)
+ */
+ public void setSunHaloIntensity(float intensity, float seconds) {
+ atmoTransition.transitionSun(atmosphere, intensity, seconds);
+ }
+
+ /**
+ * Transition sunset intensity.
+ *
+ * @param intensity target intensity (≥0)
+ * @param seconds transition duration in seconds (≥0)
+ */
+ public void setSunsetIntensity(float intensity, float seconds) {
+ atmoTransition.transitionSunset(atmosphere, intensity, seconds);
+ }
+
+ /**
+ * Copy the latest lighting output snapshot.
+ *
+ * @return copied lighting snapshot
+ */
+ public SkyLightingSnapshot lightingSnapshot() {
+ ensureEnvRuntime();
+ return lastLightSnapshot.copy();
+ }
+
/**
* Calculate the angular diameter of the moon.
*
@@ -283,11 +371,10 @@ public float lunarDiameter() {
* storeResult or a new vector)
*/
public Vector3f moonDirection(Vector3f storeResult) {
- float solarLongitude = sunAndStars.getSolarLongitude();
- float celestialLongitude = solarLongitude + longitudeDifference;
- celestialLongitude = MyMath.modulo(celestialLongitude, FastMath.TWO_PI);
- Vector3f result = sunAndStars.convertToWorld(
- lunarLatitude, celestialLongitude, storeResult);
+ float longitudeDifference = moonLongitudeDifference();
+ float lunarLatitude = moonLatitude();
+ Vector3f result = SkyMoonRuntime.direction(sunAndStars,
+ longitudeDifference, lunarLatitude, storeResult);
return result;
}
@@ -302,6 +389,31 @@ public void setCloudModulation(boolean newValue) {
this.cloudModulationFlag = newValue;
}
+ /**
+ * Transition cloud layers to a weather preset and update environment state.
+ *
+ * @param preset target cloud preset (not null)
+ * @param seconds transition duration in seconds (≥0)
+ */
+ @Override
+ public void setCloudPreset(SkyCloudPreset preset, float seconds) {
+ ensureEnvRuntime();
+ environmentRuntime.setWeather(preset, seconds);
+ }
+
+ /**
+ * Transition cloud layers to a data-driven weather preset and update state.
+ *
+ * @param definition target cloud preset definition (not null)
+ * @param seconds transition duration in seconds (≥0)
+ */
+ @Override
+ public void setCloudPreset(SkyCloudPresetDefinition definition,
+ float seconds) {
+ ensureEnvRuntime();
+ environmentRuntime.setWeather(definition, seconds);
+ }
+
/**
* Alter the daytime clear-sky color.
*
@@ -342,12 +454,51 @@ public void setMoonRenderer(GlobeRenderer newRenderer) {
this.moonRenderer = newRenderer;
if (moonRenderer.isEnabled()) {
+ this.moonAssetPath = null;
+ this.moonColorMap = null;
Texture dynamicTexture = moonRenderer.getTexture();
SkyMaterial topMaterial = getTopMaterial();
topMaterial.addObject(moonIndex, dynamicTexture);
}
}
+ /** Remove any custom moon texture and return to phase-preset images. */
+ public void clearMoonTexture() {
+ this.moonAssetPath = null;
+ this.moonColorMap = null;
+ setPhase(phase);
+ }
+
+ /**
+ * Alter the moon's color map texture using an asset path.
+ *
+ * @param assetPath path to the texture asset (not null, not empty)
+ */
+ public void setMoonTexture(String assetPath) {
+ Validate.nonEmpty(assetPath, "asset path");
+ this.moonAssetPath = assetPath;
+ this.moonColorMap = null;
+ if (moonRenderer != null) {
+ moonRenderer.setEnabled(false);
+ }
+ applyMoonTexture();
+ }
+
+ /**
+ * Alter the moon's color map texture using a pre-loaded texture.
+ *
+ * @param colorMap texture to apply (not null)
+ */
+ public void setMoonTexture(Texture colorMap) {
+ Validate.nonNull(colorMap, "texture");
+ this.moonAssetPath = null;
+ this.moonColorMap = colorMap;
+ if (moonRenderer != null) {
+ moonRenderer.setEnabled(false);
+ }
+ applyMoonTexture();
+ }
+
/**
* Alter the phase of the moon to a pre-set value.
*
@@ -355,7 +506,7 @@ public void setMoonRenderer(GlobeRenderer newRenderer) {
*/
final public void setPhase(LunarPhase newPreset) {
if (newPreset == LunarPhase.CUSTOM) {
- setPhase(longitudeDifference, lunarLatitude);
+ setPhase(moonLongitudeDifference(), moonLatitude());
return;
}
@@ -364,15 +515,11 @@ final public void setPhase(LunarPhase newPreset) {
}
this.phase = newPreset;
if (newPreset != null) {
- this.longitudeDifference = newPreset.longitudeDifference();
- SkyMaterial topMaterial = getTopMaterial();
-
- String assetPath = newPreset.imagePath("");
- try {
- topMaterial.addObject(moonIndex, assetPath);
- } catch (AssetNotFoundException exception) {
- assetPath = newPreset.imagePath("-nonviral");
- topMaterial.addObject(moonIndex, assetPath);
+ setMoonLongDiff(newPreset.longitudeDifference());
+ if (!applyMoonTexture()) {
+ SkyMaterial topMaterial = getTopMaterial();
+ SkyMoonRuntime.applyPresetTexture(
+ topMaterial, moonIndex, newPreset);
}
}
}
@@ -394,8 +541,9 @@ public void setPhase(float longitudeDifference, float lunarLatitude) {
moonRenderer.setEnabled(true);
this.phase = LunarPhase.CUSTOM;
- this.longitudeDifference = longitudeDifference;
- this.lunarLatitude = lunarLatitude;
+ setCelestialPhase(longitudeDifference, lunarLatitude);
+ this.moonAssetPath = null;
+ this.moonColorMap = null;
Texture dynamicTexture = moonRenderer.getTexture();
SkyMaterial topMaterial = getTopMaterial();
@@ -421,28 +569,79 @@ public void setSolarDiameter(float newDiameter) {
/**
* Alter the sun's color map.
*
- * @param assetPath to new color map (not null)
+ * @param assetPath path to the texture asset (not null, not empty)
*/
final public void setSunStyle(String assetPath) {
- Validate.nonNull(assetPath, "path");
+ setSunTexture(assetPath);
+ }
+ /**
+ * Alter the sun's color map texture using an asset path.
+ *
+ * @param assetPath path to the texture asset (not null, not empty)
+ */
+ public void setSunTexture(String assetPath) {
+ Validate.nonEmpty(assetPath, "asset path");
SkyMaterial topMaterial = getTopMaterial();
topMaterial.addObject(sunIndex, assetPath);
}
+ /**
+ * Alter the sun's color map texture using a pre-loaded texture.
+ *
+ * @param colorMap texture to apply (not null)
+ */
+ public void setSunTexture(Texture colorMap) {
+ Validate.nonNull(colorMap, "texture");
+ setObjectTexture(sunIndex, colorMap);
+ }
+
/**
* Calculate the angular diameter of the sun.
*
* @return diameter (in radians, <Pi, >0)
*/
public float solarDiameter() {
- float result = moonScale * Constants.discDiameter * FastMath.HALF_PI
+ float result = sunScale * Constants.discDiameter * FastMath.HALF_PI
/ Constants.uvScale;
assert result > 0f : result;
assert result < FastMath.PI : result;
return result;
}
+
+ /**
+ * Update cloud colors using the atmospheric profile.
+ *
+ * Subclasses overriding this method should preserve the contract of
+ * returning the color used for ambient-light estimation and should update
+ * every configured cloud layer.
+ *
+ * @param baseColor source color (not null, unaffected)
+ * @param sunUp true if the sun is above the horizon, otherwise false
+ * @param moonUp true if the moon is above the horizon, otherwise false
+ * @return new color used for ambient-light estimation
+ */
+ @Override
+ protected ColorRGBA updateCloudsColor(
+ ColorRGBA baseColor, boolean sunUp, boolean moonUp) {
+ assert baseColor != null;
+
+ ColorRGBA cloudsColor = MyColor.saturate(baseColor);
+ float brightness = atmosphere.getCloudDayBrightness();
+ if (!sunUp) {
+ brightness = atmosphere.getCloudNight();
+ if (moonUp) {
+ brightness += atmosphere.getCloudMoonBoost()
+ * getMoonIllumination();
+ }
+ }
+ cloudsColor.multLocal(brightness);
+ setCloudLayersColor(cloudsColor);
+
+ return cloudsColor;
+ }
+
// *************************************************************************
// SkyControlCore methods
@@ -469,10 +668,21 @@ public SkyControl clone() throws CloneNotSupportedException {
public void cloneFields(Cloner cloner, Object original) {
super.cloneFields(cloner, original);
+ final SkyControl originalControl = (SkyControl) original;
this.colorDay = cloner.clone(colorDay);
this.moonRenderer = cloner.clone(moonRenderer);
+ this.moonColorMap = cloner.clone(moonColorMap);
+ this.atmosphere = cloner.clone(atmosphere);
this.sunAndStars = cloner.clone(sunAndStars);
this.updater = cloner.clone(updater);
+ this.lastLightSnapshot
+ = originalControl.lastLightSnapshot.copy();
+ this.environmentRuntime = createEnvRuntime();
+ if (originalControl.environmentRuntime != null) {
+ this.environmentRuntime.restoreWeather(
+ originalControl.environmentRuntime.weather());
+ this.environmentRuntime.updateLighting(lastLightSnapshot);
+ }
}
/**
@@ -483,6 +693,7 @@ public void cloneFields(Cloner cloner, Object original) {
@Override
public void controlUpdate(float tpf) {
super.controlUpdate(tpf);
+ atmoTransition.update(atmosphere, tpf);
updateAll();
}
@@ -501,11 +712,20 @@ public void read(JmeImporter importer) throws IOException {
this.colorDay = (ColorRGBA) ic.readSavable(
"colorDay", new ColorRGBA(0.4f, 0.6f, 1f, Constants.alphaMax));
this.moonScale = ic.readFloat("moonScale", 0.02f);
+ this.moonAssetPath = ic.readString("moonAssetPath", null);
+ this.moonColorMap = (Texture) ic.readSavable("moonColorMap", null);
+ this.atmosphere = (SkyAtmosphere) ic.readSavable(
+ "atmosphere", new SkyAtmosphere());
this.sunScale = ic.readFloat("sunScale", 0.08f);
// moon renderer not serialized
this.phase = ic.readEnum("phase", LunarPhase.class, LunarPhase.FULL);
this.sunAndStars = (SunAndStars) ic.readSavable("sunAndStars", null);
this.updater = (Updater) ic.readSavable("updater", null);
+ this.lastLightSnapshot = SkyLightingSnapshot.empty();
+ this.environmentRuntime = createEnvRuntime();
+ if (sunAndStars != null) {
+ environmentRuntime.clock().setTimeOfDay(sunAndStars.getHour());
+ }
}
/**
@@ -523,6 +743,9 @@ public void write(JmeExporter exporter) throws IOException {
oc.write(colorDay, "colorDay",
new ColorRGBA(0.4f, 0.6f, 1f, Constants.alphaMax));
oc.write(moonScale, "moonScale", 0.02f);
+ oc.write(moonAssetPath, "moonAssetPath", null);
+ oc.write(moonColorMap, "moonColorMap", null);
+ oc.write(atmosphere, "atmosphere", null);
oc.write(sunScale, "sunScale", 0.08f);
// moon renderer not serialized
oc.write(phase, "phase", LunarPhase.FULL);
@@ -533,117 +756,77 @@ public void write(JmeExporter exporter) throws IOException {
// private methods
/**
- * Compute where mainDirection intersects the cloud dome in the dome's local
- * coordinates, accounting for the dome's flattening and vertical offset.
+ * Apply a cloud preset using the inherited visual cloud runtime.
*
- * @param mainDirection (unit vector with non-negative y-component)
- * @return new unit vector
+ * @param preset target cloud preset
+ * @param seconds transition duration in seconds
*/
- private Vector3f intersectCloudDome(Vector3f mainDirection) {
- assert mainDirection != null;
- assert mainDirection.isUnitVector() : mainDirection;
- assert mainDirection.y >= 0f : mainDirection;
-
- double cosSquared
- = MyMath.sumOfSquares(mainDirection.x, mainDirection.z);
- if (cosSquared == 0.0) {
- // Special case when the main light is directly overhead.
- return new Vector3f(0f, 1f, 0f);
- }
+ private void applyCloudPresetToCore(SkyCloudPreset preset, float seconds) {
+ super.setCloudPreset(preset, seconds);
+ }
- float deltaY;
- float semiMinorAxis;
- Geometry cloudsOnlyDome = getCloudsOnlyDome();
- if (cloudsOnlyDome == null) {
- deltaY = 0f;
- semiMinorAxis = 1f;
- } else {
- Vector3f offset = cloudsOnlyDome.getLocalTranslation();
- assert offset.x == 0f : offset;
- assert offset.y <= 0f : offset;
- assert offset.z == 0f : offset;
- deltaY = offset.y;
-
- Vector3f scale = cloudsOnlyDome.getLocalScale();
- assert scale.x == 1f : scale;
- assert scale.y > 0f : scale;
- assert scale.z == 1f : scale;
- semiMinorAxis = scale.y;
- }
- /*
- * Solve for the most positive root of a quadratic equation
- * in w = sqrt(x^2 + z^2). Use double precision arithmetic.
- */
- double cosAltitude = Math.sqrt(cosSquared);
- double tanAltitude = mainDirection.y / cosAltitude;
- double smaSquared = semiMinorAxis * semiMinorAxis;
- double a = tanAltitude * tanAltitude + smaSquared;
- assert a > 0.0 : a;
- double b = -2.0 * deltaY * tanAltitude;
- double c = deltaY * deltaY - smaSquared;
- double discriminant = MyMath.discriminant(a, b, c);
- assert discriminant >= 0.0 : discriminant;
- double w = (-b + Math.sqrt(discriminant)) / (2.0 * a);
-
- double distance = w / cosAltitude;
- if (distance > 1.0) { // Squash rounding errors.
- distance = 1.0;
- }
- float x = (float) (mainDirection.x * distance);
- float y = (float) MyMath.circle(w);
- float z = (float) (mainDirection.z * distance);
- Vector3f result = new Vector3f(x, y, z);
+ /**
+ * Apply a data-driven cloud preset using the inherited runtime.
+ *
+ * @param definition target cloud preset definition
+ * @param seconds transition duration in seconds
+ */
+ private void applyCloudPresetToCore(SkyCloudPresetDefinition definition,
+ float seconds) {
+ super.setCloudPreset(definition, seconds);
+ }
- assert result.isUnitVector() : result;
+ /**
+ * Create the game-facing environment runtime.
+ *
+ * @return new runtime
+ */
+ private SkyEnvironmentRuntime createEnvRuntime() {
+ SkyEnvironmentRuntime result = new SkyEnvironmentRuntime(
+ new SkyWorldClock.TimeApplier() {
+ @Override
+ public void applyTimeOfDay(float timeOfDayHours) {
+ if (sunAndStars != null) {
+ sunAndStars.setHour(timeOfDayHours);
+ }
+ }
+ },
+ new SkyEnvironmentRuntime.WeatherApplier() {
+ @Override
+ public void applyWeather(
+ SkyCloudPreset preset, float seconds) {
+ applyCloudPresetToCore(preset, seconds);
+ }
+
+ @Override
+ public void applyWeather(
+ SkyCloudPresetDefinition definition,
+ float seconds) {
+ applyCloudPresetToCore(definition, seconds);
+ }
+ });
return result;
}
/**
- * Compute the clockwise (left-handed) rotation of the moon's texture
- * relative to the sky's texture.
- *
- * @param longitude the moon's celestial longitude (in radians east of the
- * March equinox)
- * @param uvCenter texture coordinates of the moon's center (not null)
- * @return new unit vector with its x-component equal to the cosine of the
- * rotation angle and its y-component equal to the sine of the rotation
- * angle
+ * Ensure the environment runtime exists.
*/
- private Vector2f lunarRotation(float longitude, Vector2f uvCenter) {
- assert uvCenter != null;
- /*
- * Compute UV coordinates for 0.01 radians north of the center
- * of the moon.
- */
- DomeMesh topMesh = getTopMesh();
- float latitude = lunarLatitude + 0.01f;
- if (latitude <= FastMath.HALF_PI) {
- Vector3f north
- = sunAndStars.convertToWorld(latitude, longitude, null);
- Vector2f uvNorth = topMesh.directionUV(north);
- if (uvNorth != null) {
- Vector2f offset = uvNorth.subtract(uvCenter);
- assert offset.length() > 0f : offset;
- Vector2f result = offset.normalize();
- return result;
- }
- }
- /*
- * Compute UV coordinates for 0.01 radians south of the center
- * of the moon.
- */
- latitude = lunarLatitude - 0.01f;
- assert latitude >= -FastMath.HALF_PI : lunarLatitude;
- Vector3f south = sunAndStars.convertToWorld(latitude, longitude, null);
- Vector2f uvSouth = topMesh.directionUV(south);
- if (uvSouth != null) {
- Vector2f offset = uvCenter.subtract(uvSouth);
- assert offset.length() > 0f : offset;
- Vector2f result = offset.normalize();
- return result;
+ private void ensureEnvRuntime() {
+ if (environmentRuntime == null) {
+ environmentRuntime = createEnvRuntime();
}
- assert false : south;
- return null;
+ }
+
+ /**
+ * Apply the custom moon texture, if one has been specified.
+ *
+ * @return true if a custom texture was applied, otherwise false
+ */
+ private boolean applyMoonTexture() {
+ SkyMaterial topMaterial = getTopMaterial();
+ boolean result = SkyMoonRuntime.applyTexture(
+ topMaterial, moonIndex, moonAssetPath, moonColorMap);
+ return result;
}
/**
@@ -657,7 +840,8 @@ private void updateAll() {
*/
Vector3f sunDirection = updateSun();
ColorRGBA clearColor = colorDay.clone();
- clearColor.a = FastMath.saturate(1f + sunDirection.y / limitOfTwilight);
+ float twilightLimit = atmosphere.getTwilightLimit();
+ clearColor.a = FastMath.saturate(1f + sunDirection.y / twilightLimit);
SkyMaterial topMaterial = getTopMaterial();
topMaterial.setClearColor(clearColor);
@@ -678,61 +862,28 @@ private void updateAll() {
* @param moonDirection world direction to the moon (length=1 or null)
*/
private void updateLighting(Vector3f sunDirection, Vector3f moonDirection) {
- assert sunDirection != null;
- assert sunDirection.isUnitVector() : sunDirection;
- if (moonDirection != null) {
- assert moonDirection.isUnitVector() : moonDirection;
- }
-
- float sineSolarAltitude = sunDirection.y;
- float sineLunarAltitude;
- if (moonDirection != null) {
- sineLunarAltitude = moonDirection.y;
- } else {
- sineLunarAltitude = -1f;
- }
- updateObjectColors(sineSolarAltitude, sineLunarAltitude);
-
- // Determine the world direction to the main light source.
- boolean moonUp = sineLunarAltitude >= 0f;
- boolean sunUp = sineSolarAltitude >= 0f;
float moonWeight = getMoonIllumination();
- Vector3f mainDirection;
- if (sunUp) {
- mainDirection = sunDirection;
- } else if (moonUp && moonWeight > 0f) {
- assert moonDirection != null;
- mainDirection = moonDirection;
- } else {
- mainDirection = starlightDirection;
- }
- assert mainDirection.isUnitVector() : mainDirection;
- assert mainDirection.y >= 0f : mainDirection;
+ SkyLightSelectionRuntime.Result lightSelection
+ = SkyLightSelectionRuntime.select(
+ sunDirection, moonDirection, moonWeight,
+ starlightDirection);
+ float sineSolarAltitude = lightSelection.sineSolarAltitude();
+ float sineLunarAltitude = lightSelection.sineLunarAltitude();
+ boolean moonUp = lightSelection.moonUp();
+ boolean sunUp = lightSelection.sunUp();
+ Vector3f mainDirection = lightSelection.mainDirection();
+
+ SkyMaterial topMaterial = getTopMaterial();
+ SkyObjectLightingRuntime.updateObjects(
+ topMaterial, atmosphere, sineSolarAltitude, sineLunarAltitude);
/*
* Determine the base color (applied to horizon haze, bottom dome, and
- * viewport backgrounds) using the sun's altitude:
- * + sunlight when ssa >= 0.25,
- * + twilight when ssa = 0,
- * + blend of moonlight and starlight when ssa <= -0.04,
- * with linearly interpolated transitions.
+ * viewport backgrounds) using the sun's altitude.
*/
- ColorRGBA baseColor;
- if (sunUp) {
- float dayWeight = FastMath.saturate(sineSolarAltitude / 0.25f);
- baseColor = MyColor.interpolateLinear(
- dayWeight, twilight, sunLight);
- } else {
- ColorRGBA blend;
- if (moonUp && moonWeight > 0f) {
- blend = MyColor.interpolateLinear(
- moonWeight, starLight, moonLight);
- } else {
- blend = starLight;
- }
- float nightWeight = FastMath.saturate(-sineSolarAltitude / 0.04f);
- baseColor = MyColor.interpolateLinear(nightWeight, twilight, blend);
- }
- SkyMaterial topMaterial = getTopMaterial();
+ SkyBaseColorRuntime.Result baseResult
+ = SkyBaseColorRuntime.compute(
+ atmosphere, sineSolarAltitude, moonUp, moonWeight);
+ ColorRGBA baseColor = baseResult.baseColor();
topMaterial.setHazeColor(baseColor);
Material bottomMaterial = getBottomMaterial();
if (bottomMaterial != null) {
@@ -741,62 +892,34 @@ private void updateLighting(Vector3f sunDirection, Vector3f moonDirection) {
ColorRGBA cloudsColor = updateCloudsColor(baseColor, sunUp, moonUp);
- // Determine what fraction of the main light passes through the clouds.
- float transmit;
- if (cloudModulationFlag && (sunUp || moonUp && moonWeight > 0f)) {
- // Modulate light intensity as clouds pass in front.
- Vector3f intersection = intersectCloudDome(mainDirection);
- DomeMesh cloudsMesh = getCloudsMesh();
- Vector2f texCoord = cloudsMesh.directionUV(intersection);
- SkyMaterial cloudsMaterial = getCloudsMaterial();
- transmit = cloudsMaterial.getTransmission(texCoord);
-
- } else {
- transmit = 1f;
- }
-
- // Determine the color and intensity of the main light.
- ColorRGBA main;
- if (sunUp) {
- /*
- * By day, the main light has the base color, modulated by
- * clouds and the cube root of the sine of the sun's altitude.
- */
- float sunFactor = transmit * MyMath.cubeRoot(sineSolarAltitude);
- main = baseColor.mult(sunFactor);
-
- } else if (moonUp) {
- /*
- * By night, the main light is a blend of moonlight and starlight,
- * with the moon's portion modulated by clouds and the moon's phase.
- */
- float moonFactor = transmit * moonWeight;
- main = MyColor.interpolateLinear(moonFactor, starLight, moonLight);
-
- } else {
- main = starLight.clone();
- }
- /*
- * The ambient light color is based on the clouds color;
- * its intensity is modulated by the "slack" left by
- * strongest component of the main light.
- */
- float slack = 1f - MyMath.max(main.r, main.g, main.b);
- assert slack >= 0f : slack;
- ColorRGBA ambient = cloudsColor.mult(slack);
- /*
- * Compute the recommended shadow intensity as the fraction of
- * the total directional light.
- */
- float mainAmount = main.r + main.g + main.b;
- float ambientAmount = ambient.r + ambient.g + ambient.b;
- float totalAmount = mainAmount + ambientAmount;
- assert totalAmount > 0f : totalAmount;
- float shadowIntensity = FastMath.saturate(mainAmount / totalAmount);
-
- // Determine the recommended bloom intensity using the sun's altitude.
- float bloomIntensity = 6f * sineSolarAltitude;
- bloomIntensity = FastMath.clamp(bloomIntensity, 0f, 1.7f);
+ SkyCloudTransmissionRuntime.Input transmissionInput
+ = new SkyCloudTransmissionRuntime.Input(
+ cloudModulationFlag, sunUp, moonUp, moonWeight,
+ mainDirection);
+ SkyCloudTransmissionRuntime.Resources transmissionResources
+ = new SkyCloudTransmissionRuntime.Resources(
+ getCloudsOnlyDome(), getCloudsMesh(),
+ getCloudsMaterial());
+ float transmit = SkyCloudTransmissionRuntime.transmission(
+ transmissionInput, transmissionResources);
+
+ SkyLightingOutputRuntime.Input outputInput
+ = new SkyLightingOutputRuntime.Input(
+ atmosphere, lightSelection, baseResult, cloudsColor,
+ moonWeight, transmit);
+ SkyLightingOutputRuntime.Result output
+ = SkyLightingOutputRuntime.compute(outputInput);
+ ColorRGBA ambient = output.ambientColor();
+ ColorRGBA main = output.mainDirectionalColor();
+ float bloomIntensity = output.bloomIntensity();
+ float shadowIntensity = output.shadowIntensity();
+
+ SkyLightingState lightingState = new SkyLightingState(
+ bloomIntensity, shadowIntensity, sunUp, moonUp);
+ lastLightSnapshot = new SkyLightingSnapshot(
+ ambient, baseColor, main, mainDirection, lightingState);
+ ensureEnvRuntime();
+ environmentRuntime.updateLighting(lastLightSnapshot);
updater.update(ambient, baseColor, main, bloomIntensity,
shadowIntensity, mainDirection);
@@ -809,68 +932,17 @@ private void updateLighting(Vector3f sunDirection, Vector3f moonDirection) {
* is hidden
*/
private Vector3f updateMoon() {
- if (phase == null) {
- SkyMaterial topMaterial = getTopMaterial();
- topMaterial.hideObject(moonIndex);
- return null;
- }
- if (phase == LunarPhase.CUSTOM) {
- assert moonRenderer != null;
- float intensity;
- intensity = 2f + FastMath.abs(longitudeDifference - FastMath.PI);
- moonRenderer.setLightIntensity(intensity);
- moonRenderer.setPhase(longitudeDifference, lunarLatitude);
- }
-
- // Compute the UV coordinates of the center of the moon.
- float solarLongitude = sunAndStars.getSolarLongitude();
- float celestialLongitude = solarLongitude + longitudeDifference;
- celestialLongitude = MyMath.modulo(celestialLongitude, FastMath.TWO_PI);
- Vector3f worldDirection = sunAndStars.convertToWorld(
- lunarLatitude, celestialLongitude, null);
- DomeMesh topMesh = getTopMesh();
- Vector2f uvCenter = topMesh.directionUV(worldDirection);
-
SkyMaterial topMaterial = getTopMaterial();
- if (uvCenter != null) {
- Vector2f rotation = lunarRotation(celestialLongitude, uvCenter);
- // Reveal the object and update its texture transform.
- topMaterial.setObjectTransform(
- moonIndex, uvCenter, moonScale, rotation);
- } else {
- topMaterial.hideObject(moonIndex);
- }
-
- return worldDirection;
- }
-
- /**
- * Update the colors of the sun and moon based on their altitudes.
- *
- * @param sineSolarAltitude (≤1, &ge:-1)
- * @param sineLunarAltitude (≤1, &ge:-1)
- */
- private void updateObjectColors(
- float sineSolarAltitude, float sineLunarAltitude) {
- assert sineSolarAltitude <= 1f : sineSolarAltitude;
- assert sineSolarAltitude >= -1f : sineSolarAltitude;
- assert sineLunarAltitude <= 1f : sineLunarAltitude;
- assert sineLunarAltitude >= -1f : sineLunarAltitude;
+ DomeMesh topMesh = getTopMesh();
+ float longitudeDifference = moonLongitudeDifference();
+ float lunarLatitude = moonLatitude();
+ SkyMoonRuntime.MoonUpdateState state
+ = new SkyMoonRuntime.MoonUpdateState(moonRenderer, moonIndex,
+ longitudeDifference, lunarLatitude, moonScale);
+ Vector3f result = SkyMoonRuntime.updateMoon(
+ topMaterial, topMesh, sunAndStars, phase, state);
- // Update the sun's color.
- float green = FastMath.saturate(3f * sineSolarAltitude);
- float blue = FastMath.saturate(sineSolarAltitude - 0.1f);
- ColorRGBA sunColor = new ColorRGBA(1f, green, blue, Constants.alphaMax);
- SkyMaterial topMaterial = getTopMaterial();
- topMaterial.setObjectColor(sunIndex, sunColor);
- topMaterial.setObjectGlow(sunIndex, sunColor);
-
- // Update the moon's color.
- green = FastMath.saturate(2f * sineLunarAltitude + 0.6f);
- blue = FastMath.saturate(5f * sineLunarAltitude + 0.1f);
- ColorRGBA moonColor
- = new ColorRGBA(1f, green, blue, Constants.alphaMax);
- topMaterial.setObjectColor(moonIndex, moonColor);
+ return result;
}
/**
diff --git a/SkyLibrary/src/main/java/jme3utilities/sky/SkyControlCore.java b/SkyLibrary/src/main/java/jme3utilities/sky/SkyControlCore.java
index 0f1c0d8..584395c 100644
--- a/SkyLibrary/src/main/java/jme3utilities/sky/SkyControlCore.java
+++ b/SkyLibrary/src/main/java/jme3utilities/sky/SkyControlCore.java
@@ -30,7 +30,6 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
import com.jme3.export.JmeExporter;
import com.jme3.export.JmeImporter;
import com.jme3.export.OutputCapsule;
-import com.jme3.export.Savable;
import com.jme3.material.Material;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
@@ -39,8 +38,6 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
import com.jme3.renderer.Camera;
import com.jme3.renderer.RenderManager;
import com.jme3.renderer.ViewPort;
-import com.jme3.renderer.queue.RenderQueue.Bucket;
-import com.jme3.renderer.queue.RenderQueue.ShadowMode;
import com.jme3.scene.Geometry;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
@@ -49,12 +46,18 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
import java.io.IOException;
import java.util.logging.Level;
import java.util.logging.Logger;
-import jme3utilities.MyAsset;
import jme3utilities.MySpatial;
import jme3utilities.SubtreeControl;
import jme3utilities.Validate;
import jme3utilities.math.MyColor;
import jme3utilities.mesh.DomeMesh;
+import jme3utilities.sky.cloud.SkyCloudPreset;
+import jme3utilities.sky.cloud.SkyCloudPresetDefinition;
+import jme3utilities.sky.control.SkyCelestialState;
+import jme3utilities.sky.control.SkyCloudRuntime;
+import jme3utilities.sky.material.SkyMaterialFactory;
+import jme3utilities.sky.scene.SkyDomeFactory;
+import jme3utilities.sky.scene.SkySceneLookup;
/**
* Core fields and methods of a SubtreeControl to simulate a dynamic sky.
@@ -85,58 +88,15 @@ public class SkyControlCore extends SubtreeControl {
* maximum number of cloud layers
*/
final public static int numCloudLayers = 6;
- /**
- * number of samples in each longitudinal arc of a major dome, including
- * both its top and its rim (≥2)
- */
- final private static int numLongitudinalSamples = 16;
- /**
- * number of samples around the rim of a dome (≥3)
- */
- final private static int numRimSamples = 60;
/**
* message logger for this class
*/
final private static Logger logger
= Logger.getLogger(SkyControlCore.class.getName());
- /**
- * reusable mesh for smooth, inward-facing domes
- */
- final private static DomeMesh hemisphereMesh
- = new DomeMesh(numRimSamples, numLongitudinalSamples,
- Constants.topU, Constants.topV, Constants.uvScale, true);
/**
* local copy of {@link com.jme3.math.Quaternion#IDENTITY}
*/
final private static Quaternion rotationIdentity = new Quaternion();
- /**
- * name for the bottom geometry
- */
- final private static String bottomName = "bottom";
- /**
- * name for the clouds-only geometry
- */
- final private static String cloudsName = "clouds";
- /**
- * name for the stars node
- */
- final private static String starsNodeName = "stars node";
- /**
- * name for the top geometry
- */
- final private static String topName = "top";
- /**
- * local copy of {@link com.jme3.math.Vector3f#UNIT_X}
- */
- final private static Vector3f unitX = new Vector3f(1f, 0f, 0f);
- /**
- * local copy of {@link com.jme3.math.Vector3f#UNIT_Z}
- */
- final private static Vector3f unitZ = new Vector3f(0f, 0f, 1f);
- /**
- * negative Y-axis
- */
- final private static Vector3f negativeUnitY = new Vector3f(0f, -1f, 0f);
// *************************************************************************
// fields
@@ -164,28 +124,13 @@ public class SkyControlCore extends SubtreeControl {
*/
private Camera camera;
/**
- * information about individual cloud layers - TODO privatize
+ * Runtime state and update logic for cloud layers.
*/
- protected CloudLayer[] cloudLayers;
+ private SkyCloudRuntime cloudRuntime;
/**
- * simulation time for cloud layer animations (initially 0, may be negative)
+ * Runtime state for the moon's latitude and phase angle.
*/
- private float cloudsAnimationTime = 0f;
- /**
- * rate of motion for cloud layer animations (default is 1, may be negative)
- */
- private float cloudsRate = 1f;
- /**
- * the difference in celestial longitude (lambda) between the moon and the
- * sun (in radians, measured eastward from the sun, default is Pi)
- */
- protected float longitudeDifference = FastMath.PI; // TODO privatize
- /**
- * the moon's celestial latitude (beta, in radians, measured north from the
- * ecliptic, ≥-Pi/2, ≤Pi/2, default is 0f, realistic range is -0.09 to
- * 0.09)
- */
- protected float lunarLatitude = 0f; // TODO privatize
+ private SkyCelestialState celestialState = new SkyCelestialState();
/**
* how stars are rendered: set by constructor
*/
@@ -201,7 +146,8 @@ protected SkyControlCore() {
this.bottomDomeFlag = false;
this.starsOption = StarsOption.TopDome;
this.camera = null;
- this.cloudLayers = null;
+ this.cloudRuntime = null;
+ this.celestialState = new SkyCelestialState();
}
/**
@@ -238,48 +184,25 @@ public SkyControlCore(
this.starsOption = starsOption;
this.bottomDomeFlag = bottomDomeFlag;
- // Create and initialize the sky material for sun, moon, and haze.
- int topObjects = 2; // a sun and a moon
- boolean cloudDomeFlag = cloudFlattening != 0f;
- int topCloudLayers = cloudDomeFlag ? 0 : numCloudLayers;
- SkyMaterial topMaterial
- = new SkyMaterial(assetManager, topObjects, topCloudLayers);
- topMaterial.initialize();
- topMaterial.addHaze();
- if (starsOption == StarsOption.TopDome) {
- topMaterial.addStars();
- }
-
- SkyMaterial cloudsMaterial;
- if (cloudDomeFlag) {
- // Create and initialize a separate sky material for clouds only.
- int numObjects = 0;
- cloudsMaterial
- = new SkyMaterial(assetManager, numObjects, numCloudLayers);
- cloudsMaterial.initialize();
- cloudsMaterial.getAdditionalRenderState().setDepthWrite(false);
- cloudsMaterial.setClearColor(ColorRGBA.BlackNoAlpha);
- } else {
- cloudsMaterial = topMaterial;
- }
-
- // Initialize the cloud layers.
- this.cloudLayers = new CloudLayer[numCloudLayers];
- for (int layerIndex = 0; layerIndex < numCloudLayers; ++layerIndex) {
- this.cloudLayers[layerIndex]
- = new CloudLayer(cloudsMaterial, layerIndex);
- }
-
- Material bottomMaterial;
- if (bottomDomeFlag) {
- bottomMaterial = MyAsset.createUnshadedMaterial(assetManager);
- } else {
- bottomMaterial = null;
+ boolean separateCloudDome = cloudFlattening != 0f;
+ SkyMaterial topMaterial = SkyMaterialFactory.createTop(
+ assetManager, separateCloudDome, starsOption);
+ SkyMaterial cloudsMaterial = SkyMaterialFactory.createClouds(
+ assetManager, separateCloudDome, topMaterial);
+ CloudLayer[] cloudLayers
+ = SkyCloudLayerFactory.createLayers(cloudsMaterial);
+ this.cloudRuntime = new SkyCloudRuntime(cloudLayers);
+ Material bottomMaterial = SkyMaterialFactory.createBottom(
+ assetManager, bottomDomeFlag);
+ Node subtreeNode = SkyDomeFactory.createSubtree(cloudFlattening,
+ bottomDomeFlag, topMaterial, bottomMaterial, cloudsMaterial);
+ setSubtree(subtreeNode);
+ Node starsNode = SkyDomeFactory.createDefaultStars(
+ assetManager, starsOption);
+ if (starsNode != null) {
+ SkyDomeFactory.attachStars(subtreeNode, starsNode);
}
- createSpatials(
- cloudFlattening, topMaterial, bottomMaterial, cloudsMaterial);
-
assert !isEnabled();
}
// *************************************************************************
@@ -314,10 +237,8 @@ public void clearStarMaps() {
public CloudLayer getCloudLayer(int layerIndex) {
Validate.inRange(
layerIndex, "cloud layer index", 0, numCloudLayers - 1);
- CloudLayer layer = cloudLayers[layerIndex];
-
- assert layer != null;
- return layer;
+ CloudLayer result = cloudRuntime.getLayer(layerIndex);
+ return result;
}
/**
@@ -326,7 +247,8 @@ public CloudLayer getCloudLayer(int layerIndex) {
* @return multiple of the default rate (may be negative)
*/
public float getCloudsRate() {
- return cloudsRate;
+ float result = cloudRuntime.rate();
+ return result;
}
/**
@@ -352,7 +274,8 @@ public float getCloudsYOffset() {
* @return radians east of the sun
*/
public float getLongitudeDifference() {
- return longitudeDifference;
+ float result = celestialState.longitudeDifference();
+ return result;
}
/**
@@ -361,7 +284,8 @@ public float getLongitudeDifference() {
* @return radians north of the ecliptic (≥-Pi/2, ≤Pi/2)
*/
public float getLunarLatitude() {
- return lunarLatitude;
+ float result = celestialState.lunarLatitude();
+ return result;
}
/**
@@ -372,19 +296,8 @@ public float getLunarLatitude() {
* contribution
*/
public float getMoonIllumination() {
- float fullAngle = FastMath.abs(longitudeDifference - FastMath.PI);
- if (lunarLatitude != 0f) {
- float cos = FastMath.cos(fullAngle) * FastMath.cos(lunarLatitude);
- fullAngle = FastMath.acos(cos);
- }
- assert fullAngle >= 0f : fullAngle;
- assert fullAngle <= FastMath.PI : fullAngle;
-
- float weight = 1f - FastMath.saturate(fullAngle * 0.6f);
-
- assert weight >= 0f : weight;
- assert weight <= 1f : weight;
- return weight;
+ float result = celestialState.moonIllumination();
+ return result;
}
/**
@@ -419,9 +332,7 @@ public void setCamera(Camera camera) {
public void setCloudiness(float newAlpha) {
Validate.fraction(newAlpha, "alpha");
- for (int layer = 0; layer < numCloudLayers; ++layer) {
- cloudLayers[layer].setOpacity(newAlpha);
- }
+ cloudRuntime.setCloudiness(newAlpha);
}
/**
@@ -430,7 +341,44 @@ public void setCloudiness(float newAlpha) {
* @param newRate multiple of the default rate (may be negative, default=1)
*/
public void setCloudsRate(float newRate) {
- this.cloudsRate = newRate;
+ cloudRuntime.setRate(newRate);
+ }
+
+ /**
+ * Transition cloud layers to a weather preset.
+ *
+ * Textures are swapped only after the affected layers fade out, then the
+ * target layers fade back in.
+ *
+ * @param preset target cloud preset (not null)
+ * @param seconds transition duration in seconds (≥0)
+ */
+ public void setCloudPreset(SkyCloudPreset preset, float seconds) {
+ Validate.nonNull(preset, "preset");
+ if (!(seconds >= 0f)) {
+ throw new IllegalArgumentException("duration must be non-negative");
+ }
+
+ cloudRuntime.transitionTo(preset, seconds);
+ }
+
+ /**
+ * Transition cloud layers to a data-driven weather preset.
+ *
+ * Textures are swapped only after the affected layers fade out, then the
+ * target layers fade back in.
+ *
+ * @param definition target cloud preset definition (not null)
+ * @param seconds transition duration in seconds (≥0)
+ */
+ public void setCloudPreset(SkyCloudPresetDefinition definition,
+ float seconds) {
+ Validate.nonNull(definition, "definition");
+ if (!(seconds >= 0f)) {
+ throw new IllegalArgumentException("duration must be non-negative");
+ }
+
+ cloudRuntime.transitionTo(definition, seconds);
}
/**
@@ -496,13 +444,13 @@ public void setStabilizeFlag(boolean newState) {
final public void setStarMaps(String assetName) {
Validate.nonEmpty(assetName, "asset name");
- Node starNode;
switch (starsOption) {
case Cube:
+ case TwoDomes:
removeStarsNode();
- starNode = MyAsset.createStarMapQuads(assetManager, assetName);
- starNode.setName(starsNodeName);
- ((Node) getSubtree()).attachChildAt(starNode, 0);
+ Node starNode = SkyDomeFactory.createStars(
+ assetManager, starsOption, assetName);
+ SkyDomeFactory.attachStars((Node) getSubtree(), starNode);
break;
case TopDome:
@@ -510,12 +458,6 @@ final public void setStarMaps(String assetName) {
topMaterial.addStars(assetName);
break;
- case TwoDomes:
- removeStarsNode();
- starNode = createStarMapDomes(assetName);
- ((Node) getSubtree()).attachChildAt(starNode, 0);
- break;
-
default:
throw new IllegalStateException("option = " + starsOption);
}
@@ -554,8 +496,7 @@ public void setTopVerticalAngle(float newAngle) {
*/
protected Geometry getBottomDome() {
Node subtreeNode = (Node) getSubtree();
- Geometry bottomDome
- = (Geometry) MySpatial.findChild(subtreeNode, bottomName);
+ Geometry bottomDome = SkySceneLookup.bottomDome(subtreeNode);
return bottomDome;
}
@@ -567,10 +508,7 @@ protected Geometry getBottomDome() {
*/
protected Material getBottomMaterial() {
Geometry bottomDome = getBottomDome();
- if (bottomDome == null) {
- return null;
- }
- Material bottomMaterial = bottomDome.getMaterial();
+ Material bottomMaterial = SkySceneLookup.material(bottomDome);
return bottomMaterial;
}
@@ -582,10 +520,7 @@ protected Material getBottomMaterial() {
*/
protected DomeMesh getBottomMesh() {
Geometry bottomDome = getBottomDome();
- if (bottomDome == null) {
- return null;
- }
- DomeMesh bottomMesh = (DomeMesh) bottomDome.getMesh();
+ DomeMesh bottomMesh = SkySceneLookup.domeMesh(bottomDome);
return bottomMesh;
}
@@ -597,8 +532,7 @@ protected DomeMesh getBottomMesh() {
*/
protected Geometry getCloudsOnlyDome() {
Node subtreeNode = (Node) getSubtree();
- Geometry cloudsOnlyDome
- = (Geometry) MySpatial.findChild(subtreeNode, cloudsName);
+ Geometry cloudsOnlyDome = SkySceneLookup.cloudsOnlyDome(subtreeNode);
return cloudsOnlyDome;
}
@@ -647,7 +581,7 @@ protected DomeMesh getCloudsMesh() {
*/
protected Node getStarsNode() {
Node subtreeNode = (Node) getSubtree();
- Node starsNode = (Node) MySpatial.findChild(subtreeNode, starsNodeName);
+ Node starsNode = SkySceneLookup.starsNode(subtreeNode);
return starsNode;
}
@@ -659,7 +593,7 @@ protected Node getStarsNode() {
*/
protected Geometry getTopDome() {
Node subtreeNode = (Node) getSubtree();
- Geometry topDome = (Geometry) MySpatial.findChild(subtreeNode, topName);
+ Geometry topDome = SkySceneLookup.topDome(subtreeNode);
assert topDome != null;
return topDome;
@@ -672,9 +606,8 @@ protected Geometry getTopDome() {
*/
protected SkyMaterial getTopMaterial() {
Geometry topDome = getTopDome();
- SkyMaterial topMaterial = (SkyMaterial) topDome.getMaterial();
+ SkyMaterial topMaterial = SkySceneLookup.skyMaterial(topDome);
- assert topMaterial != null;
return topMaterial;
}
@@ -685,7 +618,7 @@ protected SkyMaterial getTopMaterial() {
*/
protected DomeMesh getTopMesh() {
Geometry topDome = getTopDome();
- DomeMesh topMesh = (DomeMesh) topDome.getMesh();
+ DomeMesh topMesh = SkySceneLookup.domeMesh(topDome);
assert topMesh != null;
return topMesh;
@@ -714,12 +647,59 @@ protected ColorRGBA updateCloudsColor(
}
cloudsColor.multLocal(cloudBrightness);
}
- for (int layer = 0; layer < numCloudLayers; ++layer) {
- cloudLayers[layer].setColor(cloudsColor);
- }
+ setCloudLayersColor(cloudsColor);
return cloudsColor;
}
+
+ /**
+ * Apply a color to all cloud layers.
+ *
+ * @param color desired cloud color (not null, unaffected)
+ */
+ protected void setCloudLayersColor(ColorRGBA color) {
+ cloudRuntime.setColor(color);
+ }
+
+ /**
+ * Return the moon's longitude difference.
+ *
+ * @return radians east of the sun
+ */
+ protected float moonLongitudeDifference() {
+ float result = celestialState.longitudeDifference();
+ return result;
+ }
+
+ /**
+ * Return the moon's lunar latitude.
+ *
+ * @return radians north of the ecliptic
+ */
+ protected float moonLatitude() {
+ float result = celestialState.lunarLatitude();
+ return result;
+ }
+
+ /**
+ * Alter the moon's longitude difference.
+ *
+ * @param longitudeDifference radians east of the sun
+ */
+ protected void setMoonLongDiff(float longitudeDifference) {
+ celestialState.setLongitudeDifference(longitudeDifference);
+ }
+
+ /**
+ * Alter the moon's lunar latitude and phase angle.
+ *
+ * @param longitudeDifference radians east of the sun
+ * @param lunarLatitude radians north of the ecliptic
+ */
+ protected void setCelestialPhase(float longitudeDifference,
+ float lunarLatitude) {
+ celestialState.setPhase(longitudeDifference, lunarLatitude);
+ }
// *************************************************************************
// SubtreeControl methods
@@ -747,7 +727,8 @@ public void cloneFields(Cloner cloner, Object original) {
super.cloneFields(cloner, original);
this.camera = cloner.clone(camera);
- this.cloudLayers = cloner.clone(cloudLayers);
+ this.cloudRuntime.cloneFields(cloner);
+ this.celestialState = celestialState.copy();
}
/**
@@ -764,7 +745,7 @@ public void controlUpdate(float updateInterval) {
return;
}
- updateClouds(updateInterval);
+ cloudRuntime.update(updateInterval);
// Translate the sky node to center the sky on the camera.
Vector3f cameraLocation = camera.getLocation();
@@ -806,14 +787,8 @@ public void read(JmeImporter importer) throws IOException {
this.starsOption
= ic.readEnum("starsOption", StarsOption.class, starsOption);
/* camera not serialized */
- Savable[] sav = ic.readSavableArray("cloudLayers", null);
- this.cloudLayers = new CloudLayer[sav.length];
- System.arraycopy(sav, 0, cloudLayers, 0, sav.length);
-
- this.cloudsAnimationTime = ic.readFloat("cloudsAnimationTime", 0f);
- this.cloudsRate = ic.readFloat("cloudsRelativeSpeed", 1f);
- this.lunarLatitude = ic.readFloat("lunarLatitude", 0f);
- this.longitudeDifference = ic.readFloat("phaseAngle", FastMath.PI);
+ this.cloudRuntime = SkyCloudRuntime.read(ic);
+ this.celestialState = SkyCelestialState.read(ic);
}
/**
@@ -843,118 +818,12 @@ public void write(JmeExporter exporter) throws IOException {
oc.write(stabilizeFlag, "stabilizeFlag", false);
oc.write(starsOption, "starsOption", StarsOption.TopDome);
/* camera not serialized */
- oc.write(cloudLayers, "cloudLayers", null);
- oc.write(cloudsAnimationTime, "cloudsAnimationTime", 0f);
- oc.write(cloudsRate, "cloudsRelativeSpeed", 1f);
- oc.write(lunarLatitude, "lunarLatitude", 0f);
- oc.write(longitudeDifference, "phaseAngle", FastMath.PI);
+ cloudRuntime.write(oc);
+ celestialState.write(oc);
}
// *************************************************************************
// private methods
- /**
- * Create and initialize the sky node and all its dome geometries.
- *
- * @param cloudFlattening the oblateness (ellipticity) of the dome with the
- * clouds (≥ 0, <1, 0 → no flattening (hemisphere), 1 →
- * maximum flattening
- * @param topMaterial (not null)
- * @param bottomMaterial (may be null)
- * @param cloudsMaterial (not null)
- */
- private void createSpatials(float cloudFlattening, Material topMaterial,
- Material bottomMaterial, Material cloudsMaterial) {
- // Create a Node to parent the dome geometries.
- Node subtreeNode = new Node("sky node");
- subtreeNode.setQueueBucket(Bucket.Sky);
- subtreeNode.setShadowMode(ShadowMode.Off);
- setSubtree(subtreeNode);
- /*
- * Attach geometries to the sky node from the outside in
- * because they'll be rendered in that order.
- */
- switch (starsOption) {
- case Cube:
- setStarMaps("equator");
- break;
-
- case TwoDomes:
- setStarMaps("Textures/skies/star-maps");
- break;
-
- default:
- }
- Geometry topDome = new Geometry(topName, hemisphereMesh.clone());
- subtreeNode.attachChild(topDome);
- topDome.setMaterial(topMaterial);
-
- if (bottomDomeFlag) {
- DomeMesh bottomMesh = new DomeMesh(numRimSamples, 2, Constants.topU,
- Constants.topV, Constants.uvScale, true);
- Geometry bottomDome = new Geometry(bottomName, bottomMesh);
- subtreeNode.attachChild(bottomDome);
-
- Quaternion upsideDown = new Quaternion();
- upsideDown.lookAt(unitX, negativeUnitY);
- bottomDome.setLocalRotation(upsideDown);
- bottomDome.setMaterial(bottomMaterial);
- }
-
- if (cloudsMaterial != topMaterial) {
- assert cloudFlattening > 0f : cloudFlattening;
- assert cloudFlattening < 1f : cloudFlattening;
-
- Geometry cloudsOnlyDome = new Geometry(cloudsName, hemisphereMesh);
- subtreeNode.attachChild(cloudsOnlyDome);
- /*
- * Flatten the clouds-only dome in order to foreshorten clouds
- * near the horizon -- even if cloudYOffset=0.
- */
- float yScale = 1f - cloudFlattening;
- cloudsOnlyDome.setLocalScale(1f, yScale, 1f);
- cloudsOnlyDome.setMaterial(cloudsMaterial);
- }
- }
-
- /**
- * Load a star map onto a sphere formed by 2 domes, one for the northern
- * hemisphere and one for the southern hemisphere.
- *
- * @param assetPath path to an asset folder containing northern.png and
- * southern.png (not null)
- * @return a new, orphan node
- */
- private Node createStarMapDomes(String assetPath) {
- assert assetPath != null;
-
- Node starNode = new Node(starsNodeName);
-
- Geometry northGeometry = new Geometry("northern stars", hemisphereMesh);
- starNode.attachChild(northGeometry);
- String northAssetPath = assetPath + "/northern.png";
- Material northMaterial
- = MyAsset.createUnshadedMaterial(assetManager, northAssetPath);
- northGeometry.setMaterial(northMaterial);
-
- Quaternion orientNorth = new Quaternion();
- orientNorth.fromAngleAxis(-FastMath.HALF_PI, unitZ);
- northGeometry.setLocalRotation(orientNorth);
-
- Geometry southGeometry
- = new Geometry("southern stars", hemisphereMesh);
- starNode.attachChild(southGeometry);
- String southAssetPath = assetPath + "/southern.png";
- Material southMaterial
- = MyAsset.createUnshadedMaterial(assetManager, southAssetPath);
- southGeometry.setMaterial(southMaterial);
-
- Quaternion orientSouth = new Quaternion();
- orientSouth.fromAngleAxis(FastMath.HALF_PI, unitZ);
- southGeometry.setLocalRotation(orientSouth);
-
- return starNode;
- }
-
/**
* Remove the stars node (if it exists) from the scene graph.
*/
@@ -966,17 +835,4 @@ private void removeStarsNode() {
}
}
- /**
- * Update the cloud layers. (Invoked once per frame.)
- *
- * @param updateInterval time interval between updates (in seconds, ≥0)
- */
- private void updateClouds(float updateInterval) {
- assert updateInterval >= 0f : updateInterval;
-
- this.cloudsAnimationTime += updateInterval * cloudsRate;
- for (int layer = 0; layer < numCloudLayers; ++layer) {
- cloudLayers[layer].updateOffset(cloudsAnimationTime);
- }
- }
}
diff --git a/SkyLibrary/src/main/java/jme3utilities/sky/SkyMaterialCore.java b/SkyLibrary/src/main/java/jme3utilities/sky/SkyMaterialCore.java
index 91339e8..09c18d6 100644
--- a/SkyLibrary/src/main/java/jme3utilities/sky/SkyMaterialCore.java
+++ b/SkyLibrary/src/main/java/jme3utilities/sky/SkyMaterialCore.java
@@ -25,26 +25,27 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
*/
package jme3utilities.sky;
+import com.jme3.asset.AssetLoadException;
import com.jme3.asset.AssetManager;
import com.jme3.export.InputCapsule;
import com.jme3.export.JmeExporter;
import com.jme3.export.JmeImporter;
import com.jme3.export.OutputCapsule;
-import com.jme3.export.Savable;
import com.jme3.material.MatParam;
import com.jme3.material.Material;
import com.jme3.math.ColorRGBA;
-import com.jme3.math.FastMath;
import com.jme3.math.Vector2f;
-import com.jme3.texture.Image;
import com.jme3.texture.Texture;
-import com.jme3.texture.image.ImageRaster;
import java.io.IOException;
-import java.util.Locale;
+import java.util.logging.Level;
import java.util.logging.Logger;
import jme3utilities.MyAsset;
import jme3utilities.Validate;
-import jme3utilities.math.MyMath;
+import jme3utilities.sky.material.SkyCloudMaterialSlots;
+import jme3utilities.sky.material.SkyDdsTextureLoader;
+import jme3utilities.sky.material.SkyMaterialParamNames;
+import jme3utilities.sky.material.SkyObjectMaterialSlots;
+import jme3utilities.sky.material.SkyObjectTransform;
/**
* Core fields and methods of a material for a dynamic sky dome.
@@ -73,28 +74,15 @@ public class SkyMaterialCore extends Material {
*/
protected AssetManager assetManager; // TODO privatize
/**
- * maximum opacity of each cloud layer (≤1, ≥0)
+ * state and raster cache for cloud material slots: set by constructor or
+ * read().
*/
- private float[] cloudAlphas;
+ private SkyCloudMaterialSlots cloudSlots;
/**
- * scale factors of cloud layers (each >0)
+ * state for astronomical object material slots: set by constructor or
+ * read().
*/
- private float[] cloudScales;
- /**
- * scale factors of astronomical objects (each >0)
- */
- private float[] objectScales;
- /**
- * image of each cloud layer
- *
- * Since ImageRaster does not implement Savable, these are retained for use
- * by write().
- */
- private Image[] cloudImages;
- /**
- * cached rasterization of each cloud layer
- */
- private ImageRaster[] cloudsRaster;
+ private SkyObjectMaterialSlots objectSlots;
/**
* maximum number of cloud layers (≥0)
*/
@@ -103,18 +91,6 @@ public class SkyMaterialCore extends Material {
* maximum number of astronomical objects (≥0)
*/
protected int maxObjects; // TODO privatize
- /**
- * UV offset of each cloud layer
- */
- private Vector2f[] cloudOffsets;
- /**
- * sky texture coordinates of the center of each astronomical object
- */
- private Vector2f[] objectCenters;
- /**
- * rotation vectors of astronomical objects (each may be null)
- */
- private Vector2f[] objectRotations;
// *************************************************************************
// constructors
@@ -123,17 +99,11 @@ public class SkyMaterialCore extends Material {
*/
protected SkyMaterialCore() {
this.assetManager = null;
- this.cloudAlphas = null;
- this.cloudImages = null;
- this.cloudScales = null;
- this.cloudsRaster = null;
- this.cloudOffsets = null;
+ this.cloudSlots = null;
this.maxCloudLayers = 0;
this.maxObjects = 0;
- this.objectCenters = null;
- this.objectRotations = null;
- this.objectScales = null;
+ this.objectSlots = null;
}
/**
@@ -157,15 +127,9 @@ public SkyMaterialCore(AssetManager assetManager, String assetPath,
this.maxObjects = maxObjects;
this.maxCloudLayers = maxCloudLayers;
- this.cloudAlphas = new float[maxCloudLayers];
- this.cloudImages = new Image[maxCloudLayers];
- this.cloudOffsets = new Vector2f[maxCloudLayers];
- this.cloudsRaster = new ImageRaster[maxCloudLayers];
- this.cloudScales = new float[maxCloudLayers];
+ this.cloudSlots = new SkyCloudMaterialSlots(maxCloudLayers);
- this.objectCenters = new Vector2f[maxObjects];
- this.objectRotations = new Vector2f[maxObjects];
- this.objectScales = new float[maxObjects];
+ this.objectSlots = new SkyObjectMaterialSlots(maxObjects);
}
// *************************************************************************
// new methods exposed
@@ -186,22 +150,48 @@ public void addClouds(int layerIndex, String assetPath) {
= MyAsset.loadTexture(assetManager, assetPath, mipmaps);
alphaMap.setWrap(Texture.WrapMode.Repeat);
String parameterName
- = String.format(Locale.ROOT, "Clouds%dAlphaMap", layerIndex);
+ = SkyMaterialParamNames.cloudAlphaMap(layerIndex);
setTexture(parameterName, alphaMap);
- boolean firstTime = (cloudsRaster[layerIndex] == null);
- Image image = alphaMap.getImage();
- this.cloudImages[layerIndex] = image;
- this.cloudsRaster[layerIndex] = ImageRaster.create(image);
+ boolean firstTime = cloudSlots.addAlphaMap(layerIndex, alphaMap);
if (firstTime) {
- this.cloudOffsets[layerIndex] = new Vector2f();
setCloudsColor(layerIndex, ColorRGBA.White);
setCloudsOffset(layerIndex, 0f, 0f);
setCloudsScale(layerIndex, 1f);
}
}
+
+ /**
+ * Add, replace, or clear a cloud-layer normal map.
+ *
+ * @param layerIndex (<maxCloudLayers, ≥0)
+ * @param assetPath asset path to the normal map, or null to clear it
+ */
+ public void setCloudsNormalMap(int layerIndex, String assetPath) {
+ validateLayerIndex(layerIndex);
+ requireCloudLayerAdded(layerIndex);
+
+ String parameterName
+ = SkyMaterialParamNames.cloudNormalMap(layerIndex);
+ if (assetPath == null) {
+ clearParam(parameterName);
+ logger.log(Level.FINE,
+ "cloud normal map cleared: layer={0}, parameter={1}",
+ new Object[]{layerIndex, parameterName});
+ } else {
+ Validate.nonEmpty(assetPath, "asset path");
+ Texture normalMap = loadNormalMap(assetPath);
+ normalMap.setWrap(Texture.WrapMode.Repeat);
+ setTexture(parameterName, normalMap);
+ logger.log(Level.FINE,
+ "cloud normal map applied: layer={0}, parameter={1}, path={2}, image={3}",
+ new Object[]{layerIndex, parameterName, assetPath,
+ normalMap.getImage()});
+ }
+ }
+
/**
* Add an astronomical object to this material using the specified color
* map. If the object already exists, its color map is updated.
@@ -214,12 +204,10 @@ public void addObject(int objectIndex, Texture colorMap) {
Validate.nonNull(colorMap, "texture");
String parameterName
- = String.format(Locale.ROOT, "Object%dColorMap", objectIndex);
+ = SkyMaterialParamNames.objectColorMap(objectIndex);
setTexture(parameterName, colorMap);
- if (objectCenters[objectIndex] == null) {
- this.objectCenters[objectIndex] = new Vector2f();
- this.objectRotations[objectIndex] = new Vector2f();
+ if (objectSlots.addObject(objectIndex)) {
setObjectColor(objectIndex, ColorRGBA.White);
setObjectGlow(objectIndex, ColorRGBA.Black);
setObjectTransform(objectIndex, Constants.topUV, 1f, null);
@@ -235,14 +223,12 @@ public void addObject(int objectIndex, Texture colorMap) {
*/
public ColorRGBA copyCloudsColor(int layerIndex) {
validateLayerIndex(layerIndex);
- if (cloudsRaster[layerIndex] == null) {
- throw new IllegalStateException("layer not yet added");
- }
+ requireCloudLayerAdded(layerIndex);
String parameterName
- = String.format(Locale.ROOT, "Clouds%dColor", layerIndex);
+ = SkyMaterialParamNames.cloudColor(layerIndex);
ColorRGBA color = copyColor(parameterName);
- color.a = cloudAlphas[layerIndex];
+ color.a = cloudSlots.alpha(layerIndex);
return color;
}
@@ -256,12 +242,10 @@ public ColorRGBA copyCloudsColor(int layerIndex) {
*/
public ColorRGBA copyCloudsGlow(int layerIndex) {
validateLayerIndex(layerIndex);
- if (cloudsRaster[layerIndex] == null) {
- throw new IllegalStateException("layer not yet added");
- }
+ requireCloudLayerAdded(layerIndex);
String parameterName
- = String.format(Locale.ROOT, "Clouds%dGlow", layerIndex);
+ = SkyMaterialParamNames.cloudGlow(layerIndex);
ColorRGBA color = copyColor(parameterName);
return color;
@@ -276,12 +260,10 @@ public ColorRGBA copyCloudsGlow(int layerIndex) {
*/
public Vector2f copyCloudsOffset(int layerIndex) {
validateLayerIndex(layerIndex);
- if (cloudsRaster[layerIndex] == null) {
- throw new IllegalStateException("layer not yet added");
- }
+ requireCloudLayerAdded(layerIndex);
- Vector2f offset = cloudOffsets[layerIndex];
- return offset.clone();
+ Vector2f result = cloudSlots.copyOffset(layerIndex);
+ return result;
}
/**
@@ -308,12 +290,10 @@ public ColorRGBA copyColor(String name) {
*/
public ColorRGBA copyObjectColor(int objectIndex) {
validateObjectIndex(objectIndex);
- if (objectCenters[objectIndex] == null) {
- throw new IllegalStateException("object not yet added");
- }
+ requireObjectAdded(objectIndex);
String parameterName
- = String.format(Locale.ROOT, "Object%dColor", objectIndex);
+ = SkyMaterialParamNames.objectColor(objectIndex);
ColorRGBA color = copyColor(parameterName);
return color;
@@ -328,12 +308,10 @@ public ColorRGBA copyObjectColor(int objectIndex) {
*/
public ColorRGBA copyObjectGlow(int objectIndex) {
validateObjectIndex(objectIndex);
- if (objectCenters[objectIndex] == null) {
- throw new IllegalStateException("object not yet added");
- }
+ requireObjectAdded(objectIndex);
String parameterName
- = String.format(Locale.ROOT, "Object%dGlow", objectIndex);
+ = SkyMaterialParamNames.objectGlow(objectIndex);
ColorRGBA color = copyColor(parameterName);
return color;
@@ -349,12 +327,10 @@ public ColorRGBA copyObjectGlow(int objectIndex) {
*/
public Vector2f copyObjectOffset(int objectIndex) {
validateObjectIndex(objectIndex);
- if (objectCenters[objectIndex] == null) {
- throw new IllegalStateException("object not yet added");
- }
+ requireObjectAdded(objectIndex);
- Vector2f offset = objectCenters[objectIndex];
- return offset.clone();
+ Vector2f result = objectSlots.copyCenter(objectIndex);
+ return result;
}
/**
@@ -367,16 +343,10 @@ public Vector2f copyObjectOffset(int objectIndex) {
*/
public Vector2f copyObjectRotation(int objectIndex) {
validateObjectIndex(objectIndex);
- if (objectCenters[objectIndex] == null) {
- throw new IllegalStateException("object not yet added");
- }
+ requireObjectAdded(objectIndex);
- Vector2f vector = objectRotations[objectIndex];
- if (vector == null) {
- return null;
- } else {
- return vector.clone();
- }
+ Vector2f result = objectSlots.copyRotation(objectIndex);
+ return result;
}
/**
@@ -402,12 +372,10 @@ public Vector2f copyVector2(String name) {
*/
public float getCloudsScale(int layerIndex) {
validateLayerIndex(layerIndex);
- if (cloudsRaster[layerIndex] == null) {
- throw new IllegalStateException("layer not yet added");
- }
+ requireCloudLayerAdded(layerIndex);
String parameterName
- = String.format(Locale.ROOT, "Clouds%dScale", layerIndex);
+ = SkyMaterialParamNames.cloudScale(layerIndex);
float result = getFloat(parameterName);
assert result > 0f : result;
@@ -424,11 +392,9 @@ public float getCloudsScale(int layerIndex) {
*/
public float getObjectScale(int objectIndex) {
validateObjectIndex(objectIndex);
- if (objectCenters[objectIndex] == null) {
- throw new IllegalStateException("object not yet added");
- }
+ requireObjectAdded(objectIndex);
- float result = objectScales[objectIndex];
+ float result = objectSlots.scale(objectIndex);
assert result > 0f : result;
return result;
@@ -456,11 +422,9 @@ public float getFloat(String name) {
*/
public float getTransmission(int objectIndex) {
validateObjectIndex(objectIndex);
+ requireObjectAdded(objectIndex);
- Vector2f center = objectCenters[objectIndex];
- if (center == null) {
- throw new IllegalStateException("object not yet added");
- }
+ Vector2f center = objectSlots.copyCenter(objectIndex);
float result = getTransmission(center);
return result;
@@ -476,16 +440,7 @@ public float getTransmission(int objectIndex) {
public float getTransmission(Vector2f skyCoordinates) {
Validate.nonNull(skyCoordinates, "coordinates");
- float result = 1f;
- for (int layerIndex = 0; layerIndex < maxCloudLayers; ++layerIndex) {
- if (cloudsRaster[layerIndex] != null) {
- float transparency = transparency(layerIndex, skyCoordinates);
- result *= transparency;
- }
- }
-
- assert result >= Constants.alphaMin : result;
- assert result <= Constants.alphaMax : result;
+ float result = cloudSlots.transmission(skyCoordinates);
return result;
}
@@ -500,22 +455,20 @@ public float getTransmission(Vector2f skyCoordinates) {
*/
public void hideObject(int objectIndex) {
validateObjectIndex(objectIndex);
- if (objectCenters[objectIndex] == null) {
- throw new IllegalStateException("object not yet added");
- }
+ requireObjectAdded(objectIndex);
String objectParameterName
- = String.format(Locale.ROOT, "Object%dCenter", objectIndex);
+ = SkyMaterialParamNames.objectCenter(objectIndex);
setVector2(objectParameterName, hidden);
- objectCenters[objectIndex].set(hidden);
+ objectSlots.hide(objectIndex, hidden);
// Scale down the object to occupy only a few pixels in texture space.
float scale = 1000f;
String transformUParameterName
- = String.format(Locale.ROOT, "Object%dTransformU", objectIndex);
+ = SkyMaterialParamNames.objectTransformU(objectIndex);
setVector2(transformUParameterName, new Vector2f(scale, scale));
String transformVParameterName
- = String.format(Locale.ROOT, "Object%dTransformV", objectIndex);
+ = SkyMaterialParamNames.objectTransformV(objectIndex);
setVector2(transformVParameterName, new Vector2f(scale, scale));
}
@@ -528,14 +481,12 @@ public void hideObject(int objectIndex) {
public void setCloudsColor(int layerIndex, ColorRGBA newColor) {
validateLayerIndex(layerIndex);
Validate.nonNull(newColor, "color");
- if (cloudsRaster[layerIndex] == null) {
- throw new IllegalStateException("layer not yet added");
- }
+ requireCloudLayerAdded(layerIndex);
String parameterName
- = String.format(Locale.ROOT, "Clouds%dColor", layerIndex);
+ = SkyMaterialParamNames.cloudColor(layerIndex);
setColor(parameterName, newColor.clone());
- this.cloudAlphas[layerIndex] = newColor.a;
+ cloudSlots.setAlpha(layerIndex, newColor.a);
}
/**
@@ -547,12 +498,10 @@ public void setCloudsColor(int layerIndex, ColorRGBA newColor) {
public void setCloudsGlow(int layerIndex, ColorRGBA newColor) {
validateLayerIndex(layerIndex);
Validate.nonNull(newColor, "color");
- if (cloudsRaster[layerIndex] == null) {
- throw new IllegalStateException("layer not yet added");
- }
+ requireCloudLayerAdded(layerIndex);
String parameterName
- = String.format(Locale.ROOT, "Clouds%dGlow", layerIndex);
+ = SkyMaterialParamNames.cloudGlow(layerIndex);
setColor(parameterName, newColor.clone());
}
@@ -565,18 +514,13 @@ public void setCloudsGlow(int layerIndex, ColorRGBA newColor) {
*/
public void setCloudsOffset(int layerIndex, float newU, float newV) {
validateLayerIndex(layerIndex);
- if (cloudsRaster[layerIndex] == null) {
- throw new IllegalStateException("layer not yet added");
- }
+ requireCloudLayerAdded(layerIndex);
- float uOffset = MyMath.modulo(newU, 1f);
- float vOffset = MyMath.modulo(newV, 1f);
- Vector2f offset = new Vector2f(uOffset, vOffset);
+ Vector2f offset = cloudSlots.setOffset(layerIndex, newU, newV);
String parameterName
- = String.format(Locale.ROOT, "Clouds%dOffset", layerIndex);
+ = SkyMaterialParamNames.cloudOffset(layerIndex);
setVector2(parameterName, offset);
- cloudOffsets[layerIndex].set(offset);
}
/**
@@ -588,14 +532,12 @@ public void setCloudsOffset(int layerIndex, float newU, float newV) {
public void setCloudsScale(int layerIndex, float newScale) {
validateLayerIndex(layerIndex);
Validate.positive(newScale, "scale");
- if (cloudsRaster[layerIndex] == null) {
- throw new IllegalStateException("layer not yet added");
- }
+ requireCloudLayerAdded(layerIndex);
String parameterName
- = String.format(Locale.ROOT, "Clouds%dScale", layerIndex);
+ = SkyMaterialParamNames.cloudScale(layerIndex);
setFloat(parameterName, newScale);
- this.cloudScales[layerIndex] = newScale;
+ cloudSlots.setScale(layerIndex, newScale);
}
/**
@@ -607,12 +549,10 @@ public void setCloudsScale(int layerIndex, float newScale) {
public void setObjectColor(int objectIndex, ColorRGBA newColor) {
validateObjectIndex(objectIndex);
Validate.nonNull(newColor, "color");
- if (objectCenters[objectIndex] == null) {
- throw new IllegalStateException("object not yet added");
- }
+ requireObjectAdded(objectIndex);
String parameterName
- = String.format(Locale.ROOT, "Object%dColor", objectIndex);
+ = SkyMaterialParamNames.objectColor(objectIndex);
setColor(parameterName, newColor.clone());
}
@@ -625,12 +565,10 @@ public void setObjectColor(int objectIndex, ColorRGBA newColor) {
public void setObjectGlow(int objectIndex, ColorRGBA newColor) {
validateObjectIndex(objectIndex);
Validate.nonNull(newColor, "color");
- if (objectCenters[objectIndex] == null) {
- throw new IllegalStateException("object not yet added");
- }
+ requireObjectAdded(objectIndex);
String parameterName
- = String.format(Locale.ROOT, "Object%dGlow", objectIndex);
+ = SkyMaterialParamNames.objectGlow(objectIndex);
setColor(parameterName, newColor.clone());
}
@@ -653,86 +591,25 @@ public void setObjectTransform(int objectIndex, Vector2f centerUV,
if (newRotate != null) {
Validate.nonZero(newRotate, "rotation vector");
}
- if (objectCenters[objectIndex] == null) {
- throw new IllegalStateException("object not yet added");
- }
+ requireObjectAdded(objectIndex);
// Record transform parameters for save().
- this.objectCenters[objectIndex] = centerUV.clone();
- if (newRotate == null) {
- this.objectRotations[objectIndex] = null;
- } else {
- this.objectRotations[objectIndex] = newRotate.clone();
- }
- this.objectScales[objectIndex] = newScale;
+ objectSlots.setTransform(objectIndex, centerUV, newScale, newRotate);
String objectParameterName
- = String.format(Locale.ROOT, "Object%dCenter", objectIndex);
+ = SkyMaterialParamNames.objectCenter(objectIndex);
setVector2(objectParameterName, centerUV);
- Vector2f offset = centerUV.subtract(Constants.topUV);
- float topDist = offset.length();
- /*
- * The texture coordinate transforms are broken into pairs of
- * vectors because there is no Matrix2f class.
- */
- Vector2f transformU = new Vector2f();
- Vector2f transformV = new Vector2f();
- Vector2f tU = new Vector2f();
- Vector2f tV = new Vector2f();
-
- if (topDist > 0f) {
- /*
- * Stretch the image horizontally to compensate for UV distortion
- * near the horizon.
- */
- float a = offset.x / topDist;
- float b = offset.y / topDist;
- tU.set(b, -a);
- tV.set(a, b);
-
- float stretchFactor
- = 1f + Constants.stretchCoefficient * topDist * topDist;
- tU.divideLocal(stretchFactor);
-
- if (newRotate != null) {
- transformU.set(tU.x * b + tV.x * a, tU.y * b + tV.y * a);
- transformV.set(tV.x * b - tU.x * a, tV.y * b - tU.y * a);
- } else {
- transformU.set(tU);
- transformV.set(tV);
- }
-
- } else {
- // No UV distortion at the top of the dome.
- transformU.set(1f, 0f);
- transformV.set(0f, 1f);
- }
-
- if (newRotate != null) {
- // Rotate so top is toward the north horizon.
- tU.set(transformV);
- tV.set(-transformU.x, -transformU.y);
-
- // Rotate by newRotate.
- Vector2f norm = newRotate.normalize();
- transformU.set(tU.x * norm.x + tV.x * norm.y,
- tU.y * norm.x + tV.y * norm.y);
- transformV.set(tV.x * norm.x - tU.x * norm.y,
- tV.y * norm.x - tU.y * norm.y);
- }
-
- // Scale by newScale.
- transformU.divideLocal(newScale);
- transformV.divideLocal(newScale);
+ SkyObjectTransform transform
+ = SkyObjectTransform.from(centerUV, newScale, newRotate);
String transformUParameterName
- = String.format(Locale.ROOT, "Object%dTransformU", objectIndex);
- setVector2(transformUParameterName, transformU);
+ = SkyMaterialParamNames.objectTransformU(objectIndex);
+ setVector2(transformUParameterName, transform.copyTransformU());
String transformVParameterName
- = String.format(Locale.ROOT, "Object%dTransformV", objectIndex);
- setVector2(transformVParameterName, transformV);
+ = SkyMaterialParamNames.objectTransformV(objectIndex);
+ setVector2(transformVParameterName, transform.copyTransformV());
}
// *************************************************************************
// new protected methods
@@ -773,43 +650,16 @@ public void read(JmeImporter importer) throws IOException {
InputCapsule capsule = importer.getCapsule(this);
// cloud layers
- this.cloudAlphas = capsule.readFloatArray("cloudAlphas", null);
-
- Savable[] sav = capsule.readSavableArray("cloudImages", null);
- this.cloudImages = new Image[sav.length];
- System.arraycopy(sav, 0, cloudImages, 0, sav.length);
-
- sav = capsule.readSavableArray("cloudOffsets", null);
- this.cloudOffsets = new Vector2f[sav.length];
- System.arraycopy(sav, 0, cloudOffsets, 0, sav.length);
-
- this.cloudScales = capsule.readFloatArray("cloudScales", null);
+ this.cloudSlots = SkyCloudMaterialSlots.read(capsule);
// astronomical objects
- sav = capsule.readSavableArray("objectCenters", null);
- this.objectCenters = new Vector2f[sav.length];
- System.arraycopy(sav, 0, objectCenters, 0, sav.length);
-
- sav = capsule.readSavableArray("objectRotations", null);
- this.objectRotations = new Vector2f[sav.length];
- System.arraycopy(sav, 0, objectRotations, 0, sav.length);
-
- this.objectScales = capsule.readFloatArray("objectScales", null);
+ this.objectSlots = SkyObjectMaterialSlots.read(capsule);
// cached values
this.assetManager = importer.getAssetManager();
- this.maxCloudLayers = cloudImages.length;
- this.maxObjects = objectCenters.length;
-
- this.cloudsRaster = new ImageRaster[maxCloudLayers];
- for (int layerIndex = 0; layerIndex < maxCloudLayers; ++layerIndex) {
- Image image = cloudImages[layerIndex];
- if (image == null) {
- this.cloudsRaster[layerIndex] = null;
- } else {
- this.cloudsRaster[layerIndex] = ImageRaster.create(image);
- }
- }
+ this.maxCloudLayers = cloudSlots.count();
+ this.maxObjects = objectSlots.count();
+
}
/**
@@ -824,92 +674,64 @@ public void write(JmeExporter exporter) throws IOException {
OutputCapsule capsule = exporter.getCapsule(this);
- capsule.write(cloudAlphas, "cloudAlphas", null);
- capsule.write(cloudImages, "cloudImages", null);
- capsule.write(cloudOffsets, "cloudOffsets", null);
- capsule.write(cloudScales, "cloudScales", null);
+ cloudSlots.write(capsule);
- capsule.write(objectCenters, "objectCenters", null);
- capsule.write(objectRotations, "objectRotations", null);
- capsule.write(objectScales, "objectScales", null);
+ objectSlots.write(capsule);
}
+
+ /**
+ * Load a cloud normal map, including BC5 DDS fallback.
+ *
+ * @param assetPath texture asset path (not null, not empty)
+ * @return new texture
+ */
+ private Texture loadNormalMap(String assetPath) {
+ assert assetPath != null;
+
+ boolean mipmaps = false;
+ Texture result;
+ try {
+ logger.log(Level.FINER,
+ "loading cloud normal map through AssetManager: {0}",
+ assetPath);
+ result = MyAsset.loadTexture(assetManager, assetPath, mipmaps);
+ } catch (AssetLoadException exception) {
+ if (!assetPath.toLowerCase(java.util.Locale.ROOT)
+ .endsWith(".dds")) {
+ throw exception;
+ }
+ logger.log(Level.INFO,
+ "using internal compressed texture reader for cloud normal map: {0}",
+ assetPath);
+ result = SkyDdsTextureLoader.loadTexture(assetManager, assetPath);
+ }
+
+ return result;
+ }
+
// *************************************************************************
// private methods
/**
- * Estimate how much light is transmitted through an indexed cloud layer at
- * the specified texture coordinates.
+ * Verify that the specified cloud layer has been added.
*
- * @param layerIndex (<maxCloudLayers, ≥0)
- * @param skyCoordinates (unaffected, not null)
- * @return fraction of light transmitted (≤1, ≥0)
+ * @param layerIndex cloud layer index
+ * @throws IllegalStateException if the layer has not been added
*/
- private float transparency(int layerIndex, Vector2f skyCoordinates) {
- assert layerIndex >= 0 : layerIndex;
- assert layerIndex < maxCloudLayers : layerIndex;
- assert skyCoordinates != null;
- assert cloudsRaster[layerIndex] != null : layerIndex;
-
- Vector2f coord = skyCoordinates.mult(cloudScales[layerIndex]);
- coord.addLocal(cloudOffsets[layerIndex]);
- coord.x = MyMath.modulo(coord.x, Constants.uvMax);
- coord.y = MyMath.modulo(coord.y, Constants.uvMax);
- float opacity = sampleRed(cloudsRaster[layerIndex], coord);
- opacity *= cloudAlphas[layerIndex];
- float result = Constants.alphaMax - opacity;
-
- assert result >= Constants.alphaMin : result;
- assert result <= Constants.alphaMax : result;
- return result;
+ private void requireCloudLayerAdded(int layerIndex) {
+ cloudSlots.requireAdded(layerIndex);
}
/**
- * Sample the red component of a rasterized texture at the specified
- * coordinates.
- *
- * @param colorImage the texture to sample (not null, unaffected)
- * @param uv texture coordinates to sample (not null, each component <1
- * and ≥0, unaffected)
- * @return red intensity (≤1, ≥0)
- */
- private static float sampleRed(ImageRaster colorImage, Vector2f uv) {
- assert colorImage != null;
- assert uv != null;
- float u = uv.x;
- float v = uv.y;
- assert u >= Constants.uvMin : uv;
- assert u < Constants.uvMax : uv;
- assert v >= Constants.uvMin : uv;
- assert v < Constants.uvMax : uv;
-
- int width = colorImage.getWidth();
- float x = u * width;
- int x0 = (int) FastMath.floor(x);
- float xFraction1 = x - x0;
- float xFraction0 = 1 - xFraction1;
- int x1 = (x0 + 1) % width;
-
- int height = colorImage.getHeight();
- float y = v * width;
- int y0 = (int) FastMath.floor(y);
- float yFraction1 = y - y0;
- float yFraction0 = 1 - yFraction1;
- int y1 = (y0 + 1) % height;
-
- // Access the red values of the four nearest pixels.
- float r00 = colorImage.getPixel(x0, y0).r;
- float r01 = colorImage.getPixel(x1, y0).r;
- float r10 = colorImage.getPixel(x0, y1).r;
- float r11 = colorImage.getPixel(x1, y1).r;
-
- // Sample using bidirectional linear interpolation.
- float result = r00 * xFraction0 * yFraction0
- + r01 * xFraction0 * yFraction1
- + r10 * xFraction1 * yFraction0
- + r11 * xFraction1 * yFraction1;
-
- assert result >= Constants.alphaMin : result;
- assert result <= Constants.alphaMax : result;
- return result;
+ * Verify that the specified astronomical object has been added.
+ *
+ * @param objectIndex astronomical object index
+ * @throws IllegalStateException if the object has not been added
+ */
+ private void requireObjectAdded(int objectIndex) {
+ objectSlots.requireAdded(objectIndex);
}
+
+
+
}
diff --git a/SkyLibrary/src/main/java/jme3utilities/sky/Updater.java b/SkyLibrary/src/main/java/jme3utilities/sky/Updater.java
index 41e0b20..d595289 100644
--- a/SkyLibrary/src/main/java/jme3utilities/sky/Updater.java
+++ b/SkyLibrary/src/main/java/jme3utilities/sky/Updater.java
@@ -47,6 +47,7 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
import java.util.logging.Logger;
import jme3utilities.Validate;
import jme3utilities.ViewPortListener;
+import jme3utilities.sky.update.UpdaterApplier;
/**
* Component of SkyControl to keep track of all the lights, shadows, and
@@ -455,33 +456,14 @@ void update(ColorRGBA ambientColor, ColorRGBA backgroundColor,
this.direction.set(direction);
}
- if (mainLight != null) {
- ColorRGBA color = mainColor.mult(mainMultiplier);
- mainLight.setColor(color);
- /*
- * The direction of the main light is the direction in which it
- * propagates, which is the opposite of the direction to the
- * light source.
- */
- Vector3f propagationDirection = direction.negate();
- mainLight.setDirection(propagationDirection);
- }
- if (ambientLight != null) {
- ColorRGBA color = ambientColor.mult(ambientMultiplier);
- ambientLight.setColor(color);
- }
- for (BloomFilter filter : bloomFilters) {
- filter.setBloomIntensity(bloomIntensity);
- }
- for (AbstractShadowFilter> filter : shadowFilters) {
- filter.setShadowIntensity(shadowIntensity);
- }
- for (AbstractShadowRenderer renderer : shadowRenderers) {
- renderer.setShadowIntensity(shadowIntensity);
- }
- for (ViewPort viewPort : viewPorts) {
- viewPort.setBackgroundColor(backgroundColor);
- }
+ UpdaterApplier.applyMain(
+ mainLight, mainColor, mainMultiplier, direction);
+ UpdaterApplier.applyAmbient(
+ ambientLight, ambientColor, ambientMultiplier);
+ UpdaterApplier.applyBloom(bloomFilters, bloomIntensity);
+ UpdaterApplier.applyShadowFilters(shadowFilters, shadowIntensity);
+ UpdaterApplier.applyShadowRenderers(shadowRenderers, shadowIntensity);
+ UpdaterApplier.applyViewPorts(viewPorts, backgroundColor);
}
// *************************************************************************
// JmeCloneable methods
diff --git a/SkyLibrary/src/main/java/jme3utilities/sky/atmosphere/SkyAtmosphereProperties.java b/SkyLibrary/src/main/java/jme3utilities/sky/atmosphere/SkyAtmosphereProperties.java
new file mode 100644
index 0000000..1dfd9ff
--- /dev/null
+++ b/SkyLibrary/src/main/java/jme3utilities/sky/atmosphere/SkyAtmosphereProperties.java
@@ -0,0 +1,106 @@
+/*
+ Copyright (c) 2026, Take Some
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+ * Neither the name of the copyright holder nor the names of its contributors
+ may be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package jme3utilities.sky.atmosphere;
+
+import com.jme3.math.ColorRGBA;
+import java.util.Properties;
+
+/**
+ * Parsing and validation helpers for SkyAtmosphere property profiles.
+ *
+ * @author Take Some
+ */
+public final class SkyAtmosphereProperties {
+ /**
+ * Hidden constructor.
+ */
+ private SkyAtmosphereProperties() {
+ // do nothing
+ }
+
+ /**
+ * Read a color override from properties.
+ *
+ * @param properties source properties (not null)
+ * @param key property key (not null)
+ * @param fallback fallback color (not null)
+ * @return parsed or fallback color
+ */
+ public static ColorRGBA readColor(
+ Properties properties, String key, ColorRGBA fallback) {
+ String text = properties.getProperty(key);
+ if (text == null) {
+ return fallback;
+ }
+
+ String[] components = text.split(",");
+ if (components.length != 3 && components.length != 4) {
+ throw new IllegalArgumentException(
+ key + " should contain r,g,b or r,g,b,a");
+ }
+ float r = Float.parseFloat(components[0].trim());
+ float g = Float.parseFloat(components[1].trim());
+ float b = Float.parseFloat(components[2].trim());
+ float a = (components.length == 4)
+ ? Float.parseFloat(components[3].trim()) : fallback.a;
+ ColorRGBA result = new ColorRGBA(r, g, b, a);
+
+ return result;
+ }
+
+ /**
+ * Read a float override from properties.
+ *
+ * @param properties source properties (not null)
+ * @param key property key (not null)
+ * @param fallback fallback value
+ * @return parsed or fallback value
+ */
+ public static float readFloat(
+ Properties properties, String key, float fallback) {
+ String text = properties.getProperty(key);
+ if (text == null) {
+ return fallback;
+ }
+ float result = Float.parseFloat(text.trim());
+
+ return result;
+ }
+
+ /**
+ * Validate a positive fraction.
+ *
+ * @param value value to validate
+ * @param description value description (not null)
+ */
+ public static void validatePosFraction(float value, String description) {
+ if (!(value > 0f && value <= 1f)) {
+ throw new IllegalArgumentException(
+ description + " should be greater than 0"
+ + " and no more than 1");
+ }
+ }
+}
diff --git a/SkyLibrary/src/main/java/jme3utilities/sky/atmosphere/SkyAtmosphereTransitionRuntime.java b/SkyLibrary/src/main/java/jme3utilities/sky/atmosphere/SkyAtmosphereTransitionRuntime.java
new file mode 100644
index 0000000..1cea391
--- /dev/null
+++ b/SkyLibrary/src/main/java/jme3utilities/sky/atmosphere/SkyAtmosphereTransitionRuntime.java
@@ -0,0 +1,236 @@
+/*
+ Copyright (c) 2026, Take Some
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+ * Neither the name of the copyright holder nor the names of its contributors
+ may be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package jme3utilities.sky.atmosphere;
+
+import com.jme3.math.FastMath;
+import jme3utilities.Validate;
+import jme3utilities.sky.SkyAtmosphere;
+
+/**
+ * Runtime interpolator for atmosphere gradient styling.
+ *
+ * @author Take Some
+ */
+final public class SkyAtmosphereTransitionRuntime {
+ /** True while a transition is active. */
+ private boolean active;
+ /** Transition duration in seconds. */
+ private float duration;
+ /** Elapsed transition time. */
+ private float elapsed;
+ /** Initial color style scale. */
+ private float fromColor;
+ /** Initial halo style scale. */
+ private float fromHalo;
+ /** Initial horizon style scale. */
+ private float fromHorizon;
+ /** Initial moon halo intensity. */
+ private float fromMoon;
+ /** Initial sun halo intensity. */
+ private float fromSun;
+ /** Initial sunset intensity. */
+ private float fromSunset;
+ /** Target color style scale. */
+ private float toColor;
+ /** Target halo style scale. */
+ private float toHalo;
+ /** Target horizon style scale. */
+ private float toHorizon;
+ /** Target moon halo intensity. */
+ private float toMoon;
+ /** Target sun halo intensity. */
+ private float toSun;
+ /** Target sunset intensity. */
+ private float toSunset;
+
+ /**
+ * Test whether a transition is active.
+ *
+ * @return true if active, otherwise false
+ */
+ public boolean isActive() {
+ return active;
+ }
+
+ /**
+ * Start a gradient preset transition.
+ *
+ * @param atmosphere target atmosphere (not null)
+ * @param style target gradient style (not null)
+ * @param seconds transition duration in seconds (≥0)
+ */
+ public void transitionStyle(SkyAtmosphere atmosphere,
+ SkyGradientStyle style, float seconds) {
+ Validate.nonNull(atmosphere, "atmosphere");
+ Validate.nonNull(style, "style");
+ Validate.nonNegative(seconds, "seconds");
+
+ toHorizon = style.horizonScale();
+ toColor = style.colorScale();
+ toHalo = style.haloScale();
+ toSunset = style.presetSunset();
+ toSun = style.presetSunHalo();
+ toMoon = style.presetMoonHalo();
+ start(atmosphere, seconds);
+ atmosphere.setGradientStyle(style);
+ apply(atmosphere, active ? 0f : 1f);
+ }
+
+ /**
+ * Start a moon halo intensity transition.
+ *
+ * @param atmosphere target atmosphere (not null)
+ * @param intensity target intensity (≥0)
+ * @param seconds transition duration in seconds (≥0)
+ */
+ public void transitionMoon(SkyAtmosphere atmosphere, float intensity,
+ float seconds) {
+ Validate.nonNull(atmosphere, "atmosphere");
+ Validate.nonNegative(intensity, "intensity");
+ Validate.nonNegative(seconds, "seconds");
+
+ toHorizon = atmosphere.getHorizonScale();
+ toColor = atmosphere.getColorScale();
+ toHalo = atmosphere.getHaloScale();
+ toSunset = atmosphere.getSunsetIntensity();
+ toSun = atmosphere.getSunHaloIntensity();
+ toMoon = intensity;
+ start(atmosphere, seconds);
+ }
+
+ /**
+ * Start a sun halo intensity transition.
+ *
+ * @param atmosphere target atmosphere (not null)
+ * @param intensity target intensity (≥0)
+ * @param seconds transition duration in seconds (≥0)
+ */
+ public void transitionSun(SkyAtmosphere atmosphere, float intensity,
+ float seconds) {
+ Validate.nonNull(atmosphere, "atmosphere");
+ Validate.nonNegative(intensity, "intensity");
+ Validate.nonNegative(seconds, "seconds");
+
+ toHorizon = atmosphere.getHorizonScale();
+ toColor = atmosphere.getColorScale();
+ toHalo = atmosphere.getHaloScale();
+ toSunset = atmosphere.getSunsetIntensity();
+ toSun = intensity;
+ toMoon = atmosphere.getMoonHaloIntensity();
+ start(atmosphere, seconds);
+ }
+
+ /**
+ * Start a sunset intensity transition.
+ *
+ * @param atmosphere target atmosphere (not null)
+ * @param intensity target intensity (≥0)
+ * @param seconds transition duration in seconds (≥0)
+ */
+ public void transitionSunset(SkyAtmosphere atmosphere, float intensity,
+ float seconds) {
+ Validate.nonNull(atmosphere, "atmosphere");
+ Validate.nonNegative(intensity, "intensity");
+ Validate.nonNegative(seconds, "seconds");
+
+ toHorizon = atmosphere.getHorizonScale();
+ toColor = atmosphere.getColorScale();
+ toHalo = atmosphere.getHaloScale();
+ toSunset = intensity;
+ toSun = atmosphere.getSunHaloIntensity();
+ toMoon = atmosphere.getMoonHaloIntensity();
+ start(atmosphere, seconds);
+ }
+
+ /**
+ * Advance the active transition.
+ *
+ * @param atmosphere target atmosphere (not null)
+ * @param interval update interval in seconds (≥0)
+ */
+ public void update(SkyAtmosphere atmosphere, float interval) {
+ Validate.nonNull(atmosphere, "atmosphere");
+ Validate.nonNegative(interval, "interval");
+ if (!active) {
+ return;
+ }
+
+ elapsed += interval;
+ float weight = FastMath.saturate(elapsed / duration);
+ apply(atmosphere, weight);
+ if (weight >= 1f) {
+ active = false;
+ }
+ }
+
+ /**
+ * Apply an interpolation weight.
+ *
+ * @param atmosphere target atmosphere
+ * @param weight interpolation weight
+ */
+ private void apply(SkyAtmosphere atmosphere, float weight) {
+ float horizon = lerp(fromHorizon, toHorizon, weight);
+ float color = lerp(fromColor, toColor, weight);
+ float halo = lerp(fromHalo, toHalo, weight);
+ atmosphere.setGradientScales(horizon, color, halo);
+ atmosphere.setSunsetIntensity(lerp(fromSunset, toSunset, weight));
+ atmosphere.setSunHaloIntensity(lerp(fromSun, toSun, weight));
+ atmosphere.setMoonHaloIntensity(lerp(fromMoon, toMoon, weight));
+ }
+
+ /**
+ * Linear interpolation.
+ *
+ * @param start start value
+ * @param end end value
+ * @param weight interpolation weight
+ * @return interpolated value
+ */
+ private static float lerp(float start, float end, float weight) {
+ float result = start + (end - start) * weight;
+ return result;
+ }
+
+ /**
+ * Start a transition to the current target fields.
+ *
+ * @param atmosphere target atmosphere
+ * @param seconds transition duration
+ */
+ private void start(SkyAtmosphere atmosphere, float seconds) {
+ fromHorizon = atmosphere.getHorizonScale();
+ fromColor = atmosphere.getColorScale();
+ fromHalo = atmosphere.getHaloScale();
+ fromSunset = atmosphere.getSunsetIntensity();
+ fromSun = atmosphere.getSunHaloIntensity();
+ fromMoon = atmosphere.getMoonHaloIntensity();
+ duration = seconds;
+ elapsed = 0f;
+ active = seconds > 0f;
+ apply(atmosphere, active ? 0f : 1f);
+ }
+}
diff --git a/SkyLibrary/src/main/java/jme3utilities/sky/atmosphere/SkyGradientStyle.java b/SkyLibrary/src/main/java/jme3utilities/sky/atmosphere/SkyGradientStyle.java
new file mode 100644
index 0000000..b5cb82e
--- /dev/null
+++ b/SkyLibrary/src/main/java/jme3utilities/sky/atmosphere/SkyGradientStyle.java
@@ -0,0 +1,129 @@
+/*
+ Copyright (c) 2026, Take Some
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+ * Neither the name of the copyright holder nor the names of its contributors
+ may be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package jme3utilities.sky.atmosphere;
+
+/**
+ * Strength presets for atmospheric gradients and astronomical halos.
+ *
+ * @author Take Some
+ */
+public enum SkyGradientStyle {
+ /** Conservative real-world inspired gradients. */
+ REALISTIC(0.70f, 0.65f, 0.65f, 0.75f, 0.75f, 0.65f),
+ /** Expressive cinematic gradients for game visuals. */
+ CINEMATIC(1.00f, 1.00f, 1.00f, 1.25f, 1.15f, 1.10f),
+ /** Strong fantasy gradients for unreal skies. */
+ FANTASY(1.55f, 1.40f, 1.45f, 1.80f, 1.65f, 1.55f);
+
+ /** Color-shift multiplier. */
+ final private float colorScale;
+ /** Preset moon halo intensity. */
+ final private float presetMoonHalo;
+ /** Preset sun halo intensity. */
+ final private float presetSunHalo;
+ /** Preset sunset intensity. */
+ final private float presetSunset;
+ /** Halo/glow multiplier. */
+ final private float haloScale;
+ /** Horizon gradient multiplier. */
+ final private float horizonScale;
+
+ /**
+ * Instantiate a preset.
+ *
+ * @param horizonScale horizon multiplier
+ * @param colorScale color multiplier
+ * @param haloScale halo multiplier
+ * @param presetSunset preset sunset intensity
+ * @param presetSunHalo preset sun halo intensity
+ * @param presetMoonHalo preset moon halo intensity
+ */
+ SkyGradientStyle(float horizonScale, float colorScale, float haloScale,
+ float presetSunset, float presetSunHalo,
+ float presetMoonHalo) {
+ this.horizonScale = horizonScale;
+ this.colorScale = colorScale;
+ this.haloScale = haloScale;
+ this.presetSunset = presetSunset;
+ this.presetSunHalo = presetSunHalo;
+ this.presetMoonHalo = presetMoonHalo;
+ }
+
+
+ /**
+ * Return preset moon halo intensity.
+ *
+ * @return multiplier
+ */
+ public float presetMoonHalo() {
+ return presetMoonHalo;
+ }
+
+ /**
+ * Return preset sun halo intensity.
+ *
+ * @return multiplier
+ */
+ public float presetSunHalo() {
+ return presetSunHalo;
+ }
+
+ /**
+ * Return preset sunset intensity.
+ *
+ * @return multiplier
+ */
+ public float presetSunset() {
+ return presetSunset;
+ }
+
+ /**
+ * Return the color-shift multiplier.
+ *
+ * @return multiplier
+ */
+ public float colorScale() {
+ return colorScale;
+ }
+
+ /**
+ * Return the halo/glow multiplier.
+ *
+ * @return multiplier
+ */
+ public float haloScale() {
+ return haloScale;
+ }
+
+ /**
+ * Return the horizon gradient multiplier.
+ *
+ * @return multiplier
+ */
+ public float horizonScale() {
+ return horizonScale;
+ }
+}
diff --git a/SkyLibrary/src/main/java/jme3utilities/sky/atmosphere/SkyLightingModel.java b/SkyLibrary/src/main/java/jme3utilities/sky/atmosphere/SkyLightingModel.java
new file mode 100644
index 0000000..522099f
--- /dev/null
+++ b/SkyLibrary/src/main/java/jme3utilities/sky/atmosphere/SkyLightingModel.java
@@ -0,0 +1,209 @@
+/*
+ Copyright (c) 2026, Take Some
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+ * Neither the name of the copyright holder nor the names of its contributors
+ may be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package jme3utilities.sky.atmosphere;
+
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.FastMath;
+import jme3utilities.sky.SkyAtmosphere;
+
+/**
+ * Physically-inspired sky lighting calculations shared by SkyControl.
+ *
+ * @author Take Some
+ */
+public final class SkyLightingModel {
+ /**
+ * Hidden constructor.
+ */
+ private SkyLightingModel() {
+ // do nothing
+ }
+
+ /**
+ * Approximate solar transmission through the atmosphere.
+ *
+ * @param atmosphere active atmospheric profile (not null)
+ * @param sineSolarAltitude sine of the solar altitude
+ * @return transmission fraction between 0 and 1
+ */
+ public static float airMassTransmission(
+ SkyAtmosphere atmosphere, float sineSolarAltitude) {
+ float altitude = FastMath.clamp(sineSolarAltitude, 0.01f, 1f);
+ float opticalMass = 1f / (altitude + 0.15f
+ * (float) Math.pow(altitude + 0.1f, 1.25));
+ float extinction = (float) Math.exp(-0.18f
+ * atmosphere.getAirMassStrength() * (opticalMass - 1f));
+ float result = FastMath.clamp(extinction,
+ atmosphere.getMinSunTransmit(), 1f);
+
+ return result;
+ }
+
+ /**
+ * Compute daylight color after approximate atmospheric extinction.
+ *
+ * @param atmosphere active atmospheric profile (not null)
+ * @param sineSolarAltitude sine of the solar altitude
+ * @return new color
+ */
+ public static ColorRGBA daylightColor(
+ SkyAtmosphere atmosphere, float sineSolarAltitude) {
+ ColorRGBA result = atmosphere.copySunLight(null);
+ float transmission = airMassTransmission(atmosphere, sineSolarAltitude);
+ float warmWeight = 1f - smoothStep(
+ sineSolarAltitude / atmosphere.getColorShiftAltitude());
+ float horizonWeight = horizonWeight(
+ sineSolarAltitude, atmosphere.getTwilightLimit());
+ float styleScale = atmosphere.getColorScale();
+ float shift = warmWeight * atmosphere.getSunsetWarmth()
+ * atmosphere.getSunsetIntensity() * styleScale;
+ float amber = horizonWeight * atmosphere.getHazeStrength()
+ * atmosphere.getSunsetWarmth()
+ * atmosphere.getSunsetIntensity() * styleScale;
+
+ result.r *= 1f + 0.28f * amber;
+ result.g *= 1f - 0.28f * shift + 0.10f * amber;
+ result.b *= 1f - 0.70f * shift - 0.22f * amber;
+ result.multLocal(transmission);
+
+ return result;
+ }
+
+ /**
+ * Compute a horizon/twilight gradient color.
+ *
+ * @param atmosphere active atmospheric profile (not null)
+ * @param sineSolarAltitude sine of the solar altitude
+ * @return new color
+ */
+ public static ColorRGBA horizonColor(
+ SkyAtmosphere atmosphere, float sineSolarAltitude) {
+ ColorRGBA result = atmosphere.copyTwilightColor(null);
+ float horizonWeight = horizonWeight(
+ sineSolarAltitude, atmosphere.getTwilightLimit());
+ float duskWeight = smoothStep(
+ -sineSolarAltitude / atmosphere.getTwilightLimit());
+ float strength = atmosphere.getSunsetWarmth()
+ * atmosphere.getSunsetIntensity()
+ * atmosphere.getHazeStrength()
+ * atmosphere.getHorizonScale();
+ float amber = horizonWeight * strength;
+ float violet = duskWeight * horizonWeight * strength;
+
+ result.r *= 1f + 0.35f * amber + 0.12f * violet;
+ result.g *= 1f + 0.06f * amber - 0.18f * violet;
+ result.b *= 1f - 0.26f * amber + 0.42f * violet;
+
+ return result;
+ }
+
+ /**
+ * Compute the bell-shaped strength of horizon gradients.
+ *
+ * @param sineAltitude sine of altitude above horizon
+ * @param twilightLimit twilight reach below horizon
+ * @return gradient strength
+ */
+ public static float horizonWeight(float sineAltitude, float twilightLimit) {
+ float distance = Math.abs(sineAltitude) / twilightLimit;
+ float result = 1f - smoothStep(distance);
+ return result;
+ }
+
+ /**
+ * Compute lunar color after low-altitude horizon tint.
+ *
+ * @param atmosphere active atmospheric profile (not null)
+ * @param sineLunarAltitude sine of lunar altitude
+ * @return new color
+ */
+ public static ColorRGBA lunarColor(
+ SkyAtmosphere atmosphere, float sineLunarAltitude) {
+ ColorRGBA result = atmosphere.copyMoonLight(null);
+ float horizonWeight = horizonWeight(
+ sineLunarAltitude, atmosphere.getTwilightLimit());
+ float shift = horizonWeight * atmosphere.getSunsetWarmth()
+ * atmosphere.getSunsetIntensity()
+ * atmosphere.getColorScale();
+
+ result.r *= 1f + 0.10f * shift;
+ result.g *= 1f - 0.10f * shift;
+ result.b *= 1f - 0.25f * shift;
+
+ return result;
+ }
+
+ /**
+ * Compute moon halo/glow color.
+ *
+ * @param atmosphere active atmospheric profile (not null)
+ * @param sineLunarAltitude sine of lunar altitude
+ * @return new color
+ */
+ public static ColorRGBA moonGlowColor(
+ SkyAtmosphere atmosphere, float sineLunarAltitude) {
+ ColorRGBA result = lunarColor(atmosphere, sineLunarAltitude);
+ float horizonWeight = horizonWeight(
+ sineLunarAltitude, atmosphere.getTwilightLimit());
+ float glow = 0.45f + 0.35f * horizonWeight
+ * atmosphere.getHaloScale();
+ result.multLocal(glow * atmosphere.getMoonHaloIntensity());
+ return result;
+ }
+
+ /**
+ * Compute sun halo/glow color.
+ *
+ * @param atmosphere active atmospheric profile (not null)
+ * @param sineSolarAltitude sine of solar altitude
+ * @return new color
+ */
+ public static ColorRGBA sunGlowColor(
+ SkyAtmosphere atmosphere, float sineSolarAltitude) {
+ ColorRGBA result = daylightColor(atmosphere, sineSolarAltitude);
+ float horizonWeight = horizonWeight(
+ sineSolarAltitude, atmosphere.getTwilightLimit());
+ float glow = 1.15f + 1.10f * horizonWeight
+ * atmosphere.getSunsetWarmth()
+ * atmosphere.getSunsetIntensity()
+ * atmosphere.getHaloScale();
+ result.multLocal(glow * atmosphere.getSunHaloIntensity());
+ return result;
+ }
+
+ /**
+ * Smoothly remap a value from 0..1 to 0..1.
+ *
+ * @param input input value
+ * @return smoothed fraction
+ */
+ public static float smoothStep(float input) {
+ float x = FastMath.saturate(input);
+ float result = x * x * (3f - 2f * x);
+
+ return result;
+ }
+}
diff --git a/SkyLibrary/src/main/java/jme3utilities/sky/atmosphere/package-info.java b/SkyLibrary/src/main/java/jme3utilities/sky/atmosphere/package-info.java
new file mode 100644
index 0000000..6246509
--- /dev/null
+++ b/SkyLibrary/src/main/java/jme3utilities/sky/atmosphere/package-info.java
@@ -0,0 +1,29 @@
+/*
+ Copyright (c) 2026, Take Some
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+ * Neither the name of the copyright holder nor the names of its contributors
+ may be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+/**
+ * Atmospheric profile parsing and lighting calculations.
+ */
+package jme3utilities.sky.atmosphere;
diff --git a/SkyLibrary/src/main/java/jme3utilities/sky/cloud/SkyCloudAssets.java b/SkyLibrary/src/main/java/jme3utilities/sky/cloud/SkyCloudAssets.java
new file mode 100644
index 0000000..b3ab416
--- /dev/null
+++ b/SkyLibrary/src/main/java/jme3utilities/sky/cloud/SkyCloudAssets.java
@@ -0,0 +1,108 @@
+/*
+ Copyright (c) 2026, Take Some
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+ * Neither the name of the copyright holder nor the names of its contributors
+ may be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package jme3utilities.sky.cloud;
+
+/**
+ * Asset paths for built-in and optional cloud textures.
+ *
+ * jME can load DDS textures through the regular AssetManager pipeline, so the
+ * preset paths intentionally point at DDS files where richer assets are used.
+ *
+ * @author Take Some
+ */
+final public class SkyCloudAssets {
+ /** Classpath directory for current built-in cloud textures. */
+ final public static String directory = "Textures/skies/clouds";
+ /** Classpath directory for optional weather-preset cloud textures. */
+ final public static String presetDirectory = directory + "/presets";
+
+ /** Resource-side cloud weather preset registry. */
+ final public static String registry
+ = presetDirectory + "/cloud-weather-presets.json";
+
+ /** Runtime Lua ABI cloud weather preset registry. */
+ final public static String luaRegistry = "helix/lua/sky/weather.lua";
+
+ /** Existing clear fallback alpha map. */
+ final public static String clear = directory + "/clear.png";
+ /** Existing procedural FBM alpha map. */
+ final public static String fbm = directory + "/fbm.png";
+ /** Existing overcast fallback alpha map. */
+ final public static String overcast = directory + "/overcast.png";
+
+ /** Optional wispy cirrocumulus alpha map. */
+ final public static String wispyCirrocumulus
+ = presetDirectory + "/wispy/skyhat_cirrocumulus01_ap.dds";
+ /** Optional wispy cloud alpha map. */
+ final public static String wisps
+ = presetDirectory + "/wispy/wisps_ap.dds";
+ /** Optional cloudy base alpha map. */
+ final public static String cloudyBase
+ = presetDirectory + "/cloudy/trialap.dds";
+ /** Optional shared marble/detail alpha map. */
+ final public static String marbleDetail
+ = presetDirectory + "/cloudy/cloudhat_marble02_ap.dds";
+ /** Optional rain alpha map. */
+ final public static String rain
+ = presetDirectory + "/rain/skyhat_rain02_ap.dds";
+ /** Optional storm alpha map. */
+ final public static String storm
+ = presetDirectory + "/storm/stormclouds_ap.dds";
+ /** Optional nimbus alpha map. */
+ final public static String nimbus
+ = presetDirectory + "/nimbus/final_nimbusclouds_ap.dds";
+
+
+ /** Optional wispy cirrocumulus normal map. */
+ final public static String wispyCirrocumulusNormal
+ = presetDirectory + "/wispy/skyhat_cirrocumulus01_nrm.dds";
+ /** Optional wispy cloud normal map. */
+ final public static String wispsNormal
+ = presetDirectory + "/wispy/wisps_nrm.dds";
+ /** Optional cloudy base normal map. */
+ final public static String cloudyBaseNormal
+ = presetDirectory + "/cloudy/trialn.dds";
+ /** Optional shared detail normal map. */
+ final public static String cloudyDetailNormal
+ = presetDirectory + "/cloudy/detail1_nrm.dds";
+ /** Optional rain normal map. */
+ final public static String rainNormal
+ = presetDirectory + "/rain/skyhat_rain02_n2.dds";
+ /** Optional storm cloud normal map. */
+ final public static String stormNormal
+ = presetDirectory + "/storm/stormclouds_nrm.dds";
+ /** Optional storm rain normal map. */
+ final public static String stormRainNormal
+ = presetDirectory + "/storm/skyhat_rain02_n2.dds";
+ /** Optional nimbus normal map. */
+ final public static String nimbusNormal
+ = presetDirectory + "/nimbus/final_nimbusclouds_n.dds";
+
+ /** Hidden constructor. */
+ private SkyCloudAssets() {
+ // do nothing
+ }
+}
diff --git a/SkyLibrary/src/main/java/jme3utilities/sky/cloud/SkyCloudLayerSpec.java b/SkyLibrary/src/main/java/jme3utilities/sky/cloud/SkyCloudLayerSpec.java
new file mode 100644
index 0000000..6fe0184
--- /dev/null
+++ b/SkyLibrary/src/main/java/jme3utilities/sky/cloud/SkyCloudLayerSpec.java
@@ -0,0 +1,147 @@
+/*
+ Copyright (c) 2026, Take Some
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+ * Neither the name of the copyright holder nor the names of its contributors
+ may be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package jme3utilities.sky.cloud;
+
+import jme3utilities.Validate;
+
+/**
+ * Target state for one cloud layer in a weather preset.
+ *
+ * @author Take Some
+ */
+final public class SkyCloudLayerSpec {
+ /** A transparent clear layer. */
+ final public static SkyCloudLayerSpec clear
+ = new SkyCloudLayerSpec(SkyCloudAssets.clear, 0f, 1f, 0f, 0f);
+
+ /** Asset path to the alpha map. */
+ final private String alphaMap;
+ /** Optional asset path to the normal map. */
+ final private String normalMap;
+ /** Target opacity. */
+ final private float opacity;
+ /** Target texture scale. */
+ final private float scale;
+ /** U-axis motion rate. */
+ final private float uRate;
+ /** V-axis motion rate. */
+ final private float vRate;
+
+ /**
+ * Instantiate a layer target.
+ *
+ * @param alphaMap asset path to the alpha map (not null, not empty)
+ * @param opacity target opacity (≤1, ≥0)
+ * @param scale texture scale (>0)
+ * @param uRate U-axis motion rate
+ * @param vRate V-axis motion rate
+ */
+ public SkyCloudLayerSpec(String alphaMap, float opacity, float scale,
+ float uRate, float vRate) {
+ this(alphaMap, null, opacity, scale, uRate, vRate);
+ }
+
+ /**
+ * Instantiate a layer target with a normal map.
+ *
+ * @param alphaMap asset path to the alpha map (not null, not empty)
+ * @param normalMap asset path to the normal map, or null
+ * @param opacity target opacity (≤1, ≥0)
+ * @param scale texture scale (>0)
+ * @param uRate U-axis motion rate
+ * @param vRate V-axis motion rate
+ */
+ public SkyCloudLayerSpec(String alphaMap, String normalMap, float opacity,
+ float scale, float uRate, float vRate) {
+ Validate.nonEmpty(alphaMap, "alpha map");
+ if (normalMap != null) {
+ Validate.nonEmpty(normalMap, "normal map");
+ }
+ Validate.fraction(opacity, "opacity");
+ Validate.positive(scale, "scale");
+
+ this.alphaMap = alphaMap;
+ this.normalMap = normalMap;
+ this.opacity = opacity;
+ this.scale = scale;
+ this.uRate = uRate;
+ this.vRate = vRate;
+ }
+
+ /**
+ * Return the alpha-map asset path.
+ *
+ * @return asset path
+ */
+ public String alphaMap() {
+ return alphaMap;
+ }
+
+ /**
+ * Return the optional normal-map asset path.
+ *
+ * @return asset path, or null
+ */
+ public String normalMap() {
+ return normalMap;
+ }
+
+ /**
+ * Return the target opacity.
+ *
+ * @return opacity fraction
+ */
+ public float opacity() {
+ return opacity;
+ }
+
+ /**
+ * Return the texture scale.
+ *
+ * @return scale factor
+ */
+ public float scale() {
+ return scale;
+ }
+
+ /**
+ * Return the U-axis motion rate.
+ *
+ * @return motion rate in cycles per second
+ */
+ public float uRate() {
+ return uRate;
+ }
+
+ /**
+ * Return the V-axis motion rate.
+ *
+ * @return motion rate in cycles per second
+ */
+ public float vRate() {
+ return vRate;
+ }
+}
diff --git a/SkyLibrary/src/main/java/jme3utilities/sky/cloud/SkyCloudPreset.java b/SkyLibrary/src/main/java/jme3utilities/sky/cloud/SkyCloudPreset.java
new file mode 100644
index 0000000..50231d7
--- /dev/null
+++ b/SkyLibrary/src/main/java/jme3utilities/sky/cloud/SkyCloudPreset.java
@@ -0,0 +1,147 @@
+/*
+ Copyright (c) 2026, Take Some
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+ * Neither the name of the copyright holder nor the names of its contributors
+ may be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package jme3utilities.sky.cloud;
+
+/**
+ * Cloud weather presets built from up to six cloud layers.
+ *
+ * @author Take Some
+ */
+public enum SkyCloudPreset {
+ /** No visible clouds. */
+ CLEAR(SkyCloudLayerSpec.clear),
+ /** Existing procedural fair-weather cloud setup. */
+ FAIR(
+ layer(SkyCloudAssets.fbm, 0.35f, 1.50f, -0.0005f, 0.0030f),
+ layer(SkyCloudAssets.fbm, 0.18f, 2.15f, 0.0003f, 0.0010f)),
+ /** Existing overcast fallback. */
+ OVERCAST(
+ layer(SkyCloudAssets.overcast, 0.72f, 1.00f, 0.0000f, 0.0003f),
+ layer(SkyCloudAssets.fbm, 0.20f, 2.40f, 0.0002f, 0.0010f)),
+ /** Optional high-altitude wispy cloud setup. */
+ WISPY(
+ layer(SkyCloudAssets.wispyCirrocumulus,
+ SkyCloudAssets.wispyCirrocumulusNormal, 0.28f, 1.30f,
+ 0.0001f, 0.0012f),
+ layer(SkyCloudAssets.wisps, SkyCloudAssets.wispsNormal, 0.16f,
+ 2.20f, -0.0002f, 0.0020f)),
+ /** Optional varied cloudy setup. */
+ CLOUDY(
+ layer(SkyCloudAssets.cloudyBase,
+ SkyCloudAssets.cloudyBaseNormal, 0.52f, 1.20f,
+ -0.0003f, 0.0018f),
+ layer(SkyCloudAssets.marbleDetail,
+ SkyCloudAssets.cloudyDetailNormal, 0.22f, 3.00f,
+ 0.0004f, 0.0024f)),
+ /** Optional rainy setup. */
+ RAIN(
+ layer(SkyCloudAssets.rain, SkyCloudAssets.rainNormal, 0.68f,
+ 1.10f, -0.0003f, 0.0026f),
+ layer(SkyCloudAssets.cloudyBase,
+ SkyCloudAssets.cloudyBaseNormal, 0.35f, 2.00f,
+ 0.0002f, 0.0018f),
+ layer(SkyCloudAssets.marbleDetail,
+ SkyCloudAssets.cloudyDetailNormal, 0.18f, 3.50f,
+ 0.0004f, 0.0030f)),
+ /** Optional storm setup. */
+ STORM(
+ layer(SkyCloudAssets.storm, SkyCloudAssets.stormNormal, 0.85f,
+ 1.00f, -0.0004f, 0.0032f),
+ layer(SkyCloudAssets.rain, SkyCloudAssets.stormRainNormal, 0.42f,
+ 1.80f, 0.0002f, 0.0026f),
+ layer(SkyCloudAssets.marbleDetail,
+ SkyCloudAssets.cloudyDetailNormal, 0.26f, 4.00f,
+ 0.0005f, 0.0035f)),
+ /** Optional nimbus setup. */
+ NIMBUS(
+ layer(SkyCloudAssets.nimbus, SkyCloudAssets.nimbusNormal, 0.76f,
+ 1.10f, -0.0004f, 0.0020f),
+ layer(SkyCloudAssets.marbleDetail,
+ SkyCloudAssets.cloudyDetailNormal, 0.22f, 3.50f,
+ 0.0003f, 0.0024f));
+
+ /** Layer targets, in material-layer order. */
+ final private SkyCloudLayerSpec[] layers;
+
+ /**
+ * Instantiate a preset.
+ *
+ * @param layers layer targets (not null, aliased)
+ */
+ SkyCloudPreset(SkyCloudLayerSpec... layers) {
+ this.layers = layers;
+ }
+
+ /**
+ * Access the target state for a layer.
+ *
+ * @param layerIndex cloud layer index
+ * @return layer target, or clear if the preset has no layer there
+ */
+ public SkyCloudLayerSpec layer(int layerIndex) {
+ SkyCloudLayerSpec result = SkyCloudLayerSpec.clear;
+ if (layerIndex < layers.length) {
+ result = layers[layerIndex];
+ }
+
+ return result;
+ }
+
+ /**
+ * Create a layer target.
+ *
+ * @param alphaMap asset path to the alpha map
+ * @param opacity target opacity
+ * @param scale texture scale
+ * @param uRate U-axis motion rate
+ * @param vRate V-axis motion rate
+ * @return new layer target
+ */
+ private static SkyCloudLayerSpec layer(String alphaMap, float opacity,
+ float scale, float uRate, float vRate) {
+ SkyCloudLayerSpec result
+ = new SkyCloudLayerSpec(alphaMap, opacity, scale, uRate, vRate);
+ return result;
+ }
+
+ /**
+ * Create a layer target with a normal map.
+ *
+ * @param alphaMap asset path to the alpha map
+ * @param normalMap asset path to the normal map
+ * @param opacity target opacity
+ * @param scale texture scale
+ * @param uRate U-axis motion rate
+ * @param vRate V-axis motion rate
+ * @return new layer target
+ */
+ private static SkyCloudLayerSpec layer(String alphaMap, String normalMap,
+ float opacity, float scale, float uRate, float vRate) {
+ SkyCloudLayerSpec result = new SkyCloudLayerSpec(
+ alphaMap, normalMap, opacity, scale, uRate, vRate);
+ return result;
+ }
+}
diff --git a/SkyLibrary/src/main/java/jme3utilities/sky/cloud/SkyCloudPresetDefinition.java b/SkyLibrary/src/main/java/jme3utilities/sky/cloud/SkyCloudPresetDefinition.java
new file mode 100644
index 0000000..4896c45
--- /dev/null
+++ b/SkyLibrary/src/main/java/jme3utilities/sky/cloud/SkyCloudPresetDefinition.java
@@ -0,0 +1,226 @@
+/*
+ Copyright (c) 2026, Take Some
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+ * Neither the name of the copyright holder nor the names of its contributors
+ may be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package jme3utilities.sky.cloud;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import jme3utilities.Validate;
+import jme3utilities.sky.SkyControlCore;
+import jme3utilities.sky.runtime.SkyWeatherMetrics;
+
+/**
+ * Data-driven cloud/weather preset definition.
+ *
+ * @author Take Some
+ */
+final public class SkyCloudPresetDefinition {
+ /** Built-in enum alias, or null for ABI/custom presets. */
+ final private SkyCloudPreset builtIn;
+ /** Human-readable description. */
+ final private String description;
+ /** Default transition duration. */
+ final private float defaultSeconds;
+ /** Stable preset id. */
+ final private String id;
+ /** Cloud layer targets. */
+ final private List layers;
+ /** Game-facing weather metrics. */
+ final private SkyWeatherMetrics metrics;
+
+ /**
+ * Instantiate a preset definition.
+ *
+ * @param id stable preset id (not null, not empty)
+ * @param description human-readable description (not null)
+ * @param defaultSeconds default transition duration (≥0)
+ * @param layers cloud layer targets (not null, copied)
+ * @param metrics game-facing weather metrics (not null, unaffected)
+ */
+ public SkyCloudPresetDefinition(String id, String description,
+ float defaultSeconds, List layers,
+ SkyWeatherMetrics metrics) {
+ this(id, description, defaultSeconds, layers, metrics, null);
+ }
+
+ /**
+ * Instantiate a preset definition.
+ *
+ * @param id stable preset id (not null, not empty)
+ * @param description human-readable description (not null)
+ * @param defaultSeconds default transition duration (≥0)
+ * @param layers cloud layer targets (not null, copied)
+ * @param metrics game-facing weather metrics (not null, unaffected)
+ * @param builtIn built-in enum alias, or null
+ */
+ private SkyCloudPresetDefinition(String id, String description,
+ float defaultSeconds, List layers,
+ SkyWeatherMetrics metrics, SkyCloudPreset builtIn) {
+ Validate.nonEmpty(id, "id");
+ Validate.nonNull(description, "description");
+ Validate.nonNegative(defaultSeconds, "seconds");
+ Validate.nonNull(layers, "layers");
+ Validate.nonNull(metrics, "metrics");
+
+ this.id = id;
+ this.description = description;
+ this.defaultSeconds = defaultSeconds;
+ this.layers = Collections.unmodifiableList(
+ new ArrayList(layers));
+ this.metrics = metrics.copy();
+ this.builtIn = builtIn;
+ }
+
+ /**
+ * Create a definition for a built-in preset.
+ *
+ * @param preset built-in preset (not null)
+ * @return new definition
+ */
+ public static SkyCloudPresetDefinition fromPreset(SkyCloudPreset preset) {
+ Validate.nonNull(preset, "preset");
+
+ List specs = new ArrayList();
+ for (int layerI = 0;
+ layerI < SkyControlCore.numCloudLayers; ++layerI) {
+ specs.add(preset.layer(layerI));
+ }
+ SkyWeatherMetrics metrics = builtinMetrics(preset);
+ SkyCloudPresetDefinition result = new SkyCloudPresetDefinition(
+ preset.name(), preset.name(), 60f, specs, metrics, preset);
+ return result;
+ }
+
+ /**
+ * Return the built-in enum alias, if any.
+ *
+ * @return built-in preset, or null
+ */
+ public SkyCloudPreset builtIn() {
+ return builtIn;
+ }
+
+ /**
+ * Return the default transition duration.
+ *
+ * @return duration in seconds
+ */
+ public float defaultSeconds() {
+ return defaultSeconds;
+ }
+
+ /**
+ * Return the description.
+ *
+ * @return description
+ */
+ public String description() {
+ return description;
+ }
+
+ /**
+ * Return the preset id.
+ *
+ * @return id
+ */
+ public String id() {
+ return id;
+ }
+
+ /**
+ * Access the target state for a layer.
+ *
+ * @param layerIndex cloud layer index
+ * @return layer target, or clear if no layer is defined there
+ */
+ public SkyCloudLayerSpec layer(int layerIndex) {
+ SkyCloudLayerSpec result = SkyCloudLayerSpec.clear;
+ if (layerIndex < layers.size()) {
+ result = layers.get(layerIndex);
+ }
+ return result;
+ }
+
+ /**
+ * Return the number of explicitly defined layers.
+ *
+ * @return layer count
+ */
+ public int layerCount() {
+ return layers.size();
+ }
+
+ /**
+ * Copy the game-facing weather metrics.
+ *
+ * @return copied metrics
+ */
+ public SkyWeatherMetrics metrics() {
+ return metrics.copy();
+ }
+
+ /**
+ * Return game-facing metrics for a built-in preset.
+ *
+ * @param preset built-in preset
+ * @return new metrics
+ */
+ private static SkyWeatherMetrics builtinMetrics(SkyCloudPreset preset) {
+ SkyWeatherMetrics result;
+ switch (preset) {
+ case CLEAR:
+ result = new SkyWeatherMetrics(0f, 1f, 0f, 0.05f, 0f);
+ break;
+ case FAIR:
+ result = new SkyWeatherMetrics(0.35f, 0.95f, 0f, 0.10f, 0f);
+ break;
+ case OVERCAST:
+ result = new SkyWeatherMetrics(0.72f, 0.75f, 0f, 0.25f, 0f);
+ break;
+ case WISPY:
+ result = new SkyWeatherMetrics(0.28f, 0.92f, 0f, 0.20f, 0f);
+ break;
+ case CLOUDY:
+ result = new SkyWeatherMetrics(0.55f, 0.78f, 0f, 0.35f, 0f);
+ break;
+ case RAIN:
+ result = new SkyWeatherMetrics(
+ 0.75f, 0.55f, 0.75f, 0.55f, 0.02f);
+ break;
+ case STORM:
+ result = new SkyWeatherMetrics(
+ 0.90f, 0.35f, 0.95f, 0.90f, 0.20f);
+ break;
+ case NIMBUS:
+ result = new SkyWeatherMetrics(
+ 0.82f, 0.45f, 0.80f, 0.70f, 0.08f);
+ break;
+ default:
+ throw new IllegalStateException("preset = " + preset);
+ }
+ return result;
+ }
+}
diff --git a/SkyLibrary/src/main/java/jme3utilities/sky/cloud/SkyCloudPresetLoader.java b/SkyLibrary/src/main/java/jme3utilities/sky/cloud/SkyCloudPresetLoader.java
new file mode 100644
index 0000000..bd3a36a
--- /dev/null
+++ b/SkyLibrary/src/main/java/jme3utilities/sky/cloud/SkyCloudPresetLoader.java
@@ -0,0 +1,339 @@
+/*
+ Copyright (c) 2026, Take Some
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+ * Neither the name of the copyright holder nor the names of its contributors
+ may be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package jme3utilities.sky.cloud;
+
+import com.jme3.asset.AssetInfo;
+import com.jme3.asset.AssetKey;
+import com.jme3.asset.AssetManager;
+import com.jme3.asset.AssetNotFoundException;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import jme3utilities.Validate;
+import jme3utilities.sky.runtime.SkyWeatherMetrics;
+import org.luaj.vm2.Globals;
+import org.luaj.vm2.LuaError;
+import org.luaj.vm2.LuaValue;
+import org.luaj.vm2.Varargs;
+import org.luaj.vm2.lib.jse.JsePlatform;
+
+/**
+ * Loader for Lua weather ABI preset registries.
+ *
+ * @author Take Some
+ */
+final public class SkyCloudPresetLoader {
+ /** Message logger for this class. */
+ final private static Logger logger
+ = Logger.getLogger(SkyCloudPresetLoader.class.getName());
+
+ /** Default Lua ABI registry resource. */
+ final public static String defaultRegistry
+ = SkyCloudAssets.luaRegistry;
+
+ /** Hidden constructor. */
+ private SkyCloudPresetLoader() {
+ // do nothing
+ }
+
+ /**
+ * Load a Lua weather ABI registry.
+ *
+ * @param assetManager asset manager (not null)
+ * @param assetPath Lua asset path (not null, not empty)
+ * @return loaded registry
+ */
+ public static SkyCloudPresetRegistry load(AssetManager assetManager,
+ String assetPath) {
+ Validate.nonNull(assetManager, "asset manager");
+ Validate.nonEmpty(assetPath, "asset path");
+
+ logger.log(Level.INFO,
+ "loading sky weather Lua ABI registry: {0}", assetPath);
+ String source = readText(assetManager, assetPath);
+ Globals globals = JsePlatform.standardGlobals();
+ LuaValue root;
+ try {
+ root = globals.load(source, assetPath).call();
+ } catch (LuaError exception) {
+ throw new IllegalArgumentException(
+ "Invalid sky weather Lua ABI: " + assetPath, exception);
+ }
+ SkyCloudPresetRegistry result = parseRegistry(root, assetPath);
+ logger.log(Level.INFO,
+ "loaded sky weather Lua ABI registry: path={0}, presets={1}",
+ new Object[]{assetPath, result.ids().size()});
+ return result;
+ }
+
+ /**
+ * Load the default bundled Lua weather ABI registry.
+ *
+ * @param assetManager asset manager (not null)
+ * @return loaded registry
+ */
+ public static SkyCloudPresetRegistry loadDefault(
+ AssetManager assetManager) {
+ SkyCloudPresetRegistry result = load(assetManager, defaultRegistry);
+ return result;
+ }
+
+ /**
+ * Parse one preset definition.
+ *
+ * @param id preset id
+ * @param value Lua preset table
+ * @return parsed definition
+ */
+ private static SkyCloudPresetDefinition parseDefinition(
+ String id, LuaValue value) {
+ requireTable(value, id);
+
+ String description = optionalString(value, "description", id);
+ float seconds = optionalFloat(value, "transitionSeconds", 60f);
+ SkyWeatherMetrics metrics = parseMetrics(value.get("world"));
+ List layers = parseLayers(value.get("layers"));
+ SkyCloudPresetDefinition result = new SkyCloudPresetDefinition(
+ id, description, seconds, layers, metrics);
+ logger.log(Level.FINE,
+ "parsed sky weather preset: id={0}, seconds={1}, layers={2}, metrics={3}",
+ new Object[]{id, seconds, layers.size(), metrics});
+ return result;
+ }
+
+ /**
+ * Parse one cloud layer table.
+ *
+ * @param value Lua layer table
+ * @return parsed layer spec
+ */
+ private static SkyCloudLayerSpec parseLayer(LuaValue value) {
+ requireTable(value, "layer");
+
+ String alphaMap = requiredString(value, "alphaMap");
+ String normalMap = optionalString(value, "normalMap", null);
+ float opacity = requiredFloat(value, "opacity");
+ float scale = requiredFloat(value, "scale");
+ float uRate = requiredFloat(value, "uRate");
+ float vRate = requiredFloat(value, "vRate");
+ SkyCloudLayerSpec result;
+ if (normalMap == null) {
+ result = new SkyCloudLayerSpec(
+ alphaMap, opacity, scale, uRate, vRate);
+ } else {
+ result = new SkyCloudLayerSpec(
+ alphaMap, normalMap, opacity, scale, uRate, vRate);
+ }
+ logger.log(Level.FINER,
+ "parsed cloud layer: alpha={0}, normal={1}, opacity={2}, scale={3}, uRate={4}, vRate={5}",
+ new Object[]{alphaMap, normalMap, opacity, scale, uRate, vRate});
+ return result;
+ }
+
+ /**
+ * Parse cloud layers.
+ *
+ * @param value Lua layers array
+ * @return layer specs
+ */
+ private static List parseLayers(LuaValue value) {
+ requireTable(value, "layers");
+
+ List result = new ArrayList();
+ int length = value.length();
+ for (int layerI = 1; layerI <= length; ++layerI) {
+ result.add(parseLayer(value.get(layerI)));
+ }
+ return result;
+ }
+
+ /**
+ * Parse weather metrics.
+ *
+ * @param value Lua world table
+ * @return parsed metrics
+ */
+ private static SkyWeatherMetrics parseMetrics(LuaValue value) {
+ requireTable(value, "world");
+
+ SkyWeatherMetrics result = new SkyWeatherMetrics(
+ requiredFloat(value, "cloudiness"),
+ requiredFloat(value, "visibility"),
+ requiredFloat(value, "precipitation"),
+ requiredFloat(value, "windStrength"),
+ requiredFloat(value, "lightningChance"));
+ return result;
+ }
+
+ /**
+ * Parse a root Lua ABI table.
+ *
+ * @param root root table
+ * @param assetPath asset path for diagnostics
+ * @return parsed registry
+ */
+ private static SkyCloudPresetRegistry parseRegistry(
+ LuaValue root, String assetPath) {
+ requireTable(root, assetPath);
+
+ LuaValue presets = root.get("presets");
+ requireTable(presets, "presets");
+
+ List list
+ = new ArrayList();
+ LuaValue key = LuaValue.NIL;
+ while (true) {
+ Varargs pair = presets.next(key);
+ key = pair.arg1();
+ if (key.isnil()) {
+ break;
+ }
+ LuaValue value = pair.arg(2);
+ list.add(parseDefinition(key.checkjstring(), value));
+ }
+ SkyCloudPresetRegistry result = new SkyCloudPresetRegistry(list);
+ logger.log(Level.FINE,
+ "parsed sky weather registry: path={0}, presetCount={1}",
+ new Object[]{assetPath, list.size()});
+ return result;
+ }
+
+ /**
+ * Read a text asset.
+ *
+ * @param assetManager asset manager
+ * @param assetPath asset path
+ * @return asset contents
+ */
+ private static String readText(AssetManager assetManager,
+ String assetPath) {
+ AssetKey