From aecbd23ba7aa6df1cf47a5bad38cd798b699fb41 Mon Sep 17 00:00:00 2001 From: "Lukas Rieger (Blue)" Date: Mon, 26 Feb 2024 01:59:28 +0100 Subject: [PATCH] Implement animated textures --- BlueMapCommon/webapp/src/js/MapViewer.js | 4 +- BlueMapCommon/webapp/src/js/map/Map.js | 31 ++++- .../webapp/src/js/map/TextureAnimation.js | 83 +++++++++++++ .../src/js/map/hires/HiresFragmentShader.js | 11 +- BlueMapCommon/webapp/src/js/util/Utils.js | 2 +- .../resources/resourcepack/ResourcePack.java | 19 ++- .../resourcepack/texture/AnimationMeta.java | 113 ++++++++++++++++++ .../resourcepack/texture/Texture.java | 25 ++-- 8 files changed, 265 insertions(+), 23 deletions(-) create mode 100644 BlueMapCommon/webapp/src/js/map/TextureAnimation.js create mode 100644 BlueMapCore/src/main/java/de/bluecolored/bluemap/core/resources/resourcepack/texture/AnimationMeta.java diff --git a/BlueMapCommon/webapp/src/js/MapViewer.js b/BlueMapCommon/webapp/src/js/MapViewer.js index 840aa588..c29bd9bd 100644 --- a/BlueMapCommon/webapp/src/js/MapViewer.js +++ b/BlueMapCommon/webapp/src/js/MapViewer.js @@ -294,7 +294,7 @@ export class MapViewer { } // render - if (delta >= 1000 || Date.now() - this.lastRedrawChange < 1000) { + if (delta >= 50 || Date.now() - this.lastRedrawChange < 1000) { this.lastFrame = now; this.render(delta); } @@ -325,6 +325,8 @@ export class MapViewer { if (this.map && this.map.isLoaded) { + this.map.animations.forEach(animation => animation.step(delta)) + // shift whole scene including camera towards 0,0 to tackle shader-precision issues const s = 10000; const sX = Math.round(this.camera.position.x / s) * s; diff --git a/BlueMapCommon/webapp/src/js/map/Map.js b/BlueMapCommon/webapp/src/js/map/Map.js index f99cabab..4b04239a 100644 --- a/BlueMapCommon/webapp/src/js/map/Map.js +++ b/BlueMapCommon/webapp/src/js/map/Map.js @@ -39,6 +39,7 @@ import {TileManager} from "./TileManager"; import {TileLoader} from "./TileLoader"; import {LowresTileLoader} from "./LowresTileLoader"; import {reactive} from "vue"; +import {TextureAnimation} from "@/js/map/TextureAnimation"; export class Map { @@ -86,6 +87,9 @@ export class Map { /** @type {Texture[]} */ this.loadedTextures = []; + /** @type {TextureAnimation[]} */ + this.animations = []; + /** @type {TileManager} */ this.hiresTileManager = null; /** @type {TileManager[]} */ @@ -264,7 +268,8 @@ export class Map { * resourcePath: string, * color: number[], * halfTransparent: boolean, - * texture: string + * texture: string, + * animation: any | undefined * }[]} the textures-data * @returns {ShaderMaterial[]} the hires Material (array because its a multi-material) */ @@ -293,7 +298,24 @@ export class Map { texture.wrapT = ClampToEdgeWrapping; texture.flipY = false; texture.flatShading = true; - texture.image.addEventListener("load", () => texture.needsUpdate = true); + + let animationUniforms = { + animationFrameHeight: { value: 1 }, + animationFrameIndex: { value: 0 }, + animationInterpolationFrameIndex: { value: 0 }, + animationInterpolation: { value: 0 } + }; + + let animation = null; + if (textureSettings.animation) { + animation = new TextureAnimation(animationUniforms, textureSettings.animation); + this.animations.push(animation); + } + + texture.image.addEventListener("load", () => { + texture.needsUpdate = true + if (animation) animation.init(texture.image.naturalWidth, texture.image.naturalHeight) + }); this.loadedTextures.push(texture); @@ -304,7 +326,8 @@ export class Map { type: 't', value: texture }, - transparent: { value: transparent } + transparent: { value: transparent }, + ...animationUniforms }, vertexShader: vertexShader, fragmentShader: fragmentShader, @@ -363,6 +386,8 @@ export class Map { this.loadedTextures.forEach(texture => texture.dispose()); this.loadedTextures = []; + + this.animations = []; } /** diff --git a/BlueMapCommon/webapp/src/js/map/TextureAnimation.js b/BlueMapCommon/webapp/src/js/map/TextureAnimation.js new file mode 100644 index 00000000..540f5abe --- /dev/null +++ b/BlueMapCommon/webapp/src/js/map/TextureAnimation.js @@ -0,0 +1,83 @@ + + +export class TextureAnimation { + + /** + * @param uniforms {{ + * animationFrameHeight: { value: number }, + * animationFrameIndex: { value: number }, + * animationInterpolationFrameIndex: { value: number }, + * animationInterpolation: { value: number } + * }} + * @param data {{ + * interpolate: boolean, + * width: number, + * height: number, + * frametime: number, + * frames: { + * index: number, + * time: number + * }[] | undefined + * }} + */ + constructor(uniforms, data) { + this.uniforms = uniforms; + this.data = { + interpolate: false, + width: 1, + height: 1, + frametime: 1, + ...data + }; + this.frameImages = 1; + this.frameDelta = 0; + this.frameTime = this.data.frametime * 50; + this.frames = 1; + this.frameIndex = 0; + } + + /** + * @param width {number} + * @param height {number} + */ + init(width, height) { + this.frameImages = height / width; + this.uniforms.animationFrameHeight.value = 1 / this.frameImages; + this.frames = this.frameImages; + if (this.data.frames) { + this.frames = this.data.frames.length; + } + } + + /** + * @param delta {number} + */ + step(delta) { + this.frameDelta += delta; + + if (this.frameDelta > this.frameTime) { + this.frameDelta -= this.frameTime; + this.frameDelta %= this.frameTime; + + this.frameIndex++; + this.frameIndex %= this.frames; + + if (this.data.frames) { + let frame = this.data.frames[this.frameIndex] + let nextFrame = this.data.frames[(this.frameIndex + 1) % this.frames]; + + this.uniforms.animationFrameIndex.value = frame.index; + this.uniforms.animationInterpolationFrameIndex.value = nextFrame.index; + this.frameTime = frame.time * 50; + } else { + this.uniforms.animationFrameIndex.value = this.frameIndex; + this.uniforms.animationInterpolationFrameIndex.value = (this.frameIndex + 1) % this.frames; + } + } + + if (this.data.interpolate) { + this.uniforms.animationInterpolation.value = this.frameDelta / this.frameTime; + } + } + +} \ No newline at end of file diff --git a/BlueMapCommon/webapp/src/js/map/hires/HiresFragmentShader.js b/BlueMapCommon/webapp/src/js/map/hires/HiresFragmentShader.js index 27f1d95b..5fa5da16 100644 --- a/BlueMapCommon/webapp/src/js/map/hires/HiresFragmentShader.js +++ b/BlueMapCommon/webapp/src/js/map/hires/HiresFragmentShader.js @@ -34,6 +34,10 @@ ${ShaderChunk.logdepthbuf_pars_fragment} uniform sampler2D textureImage; uniform float sunlightStrength; uniform float ambientLight; +uniform float animationFrameHeight; +uniform float animationFrameIndex; +uniform float animationInterpolationFrameIndex; +uniform float animationInterpolation; varying vec3 vPosition; //varying vec3 vWorldPosition; @@ -46,7 +50,12 @@ varying float vBlocklight; //varying float vDistance; void main() { - vec4 color = texture(textureImage, vUv); + + vec4 color = texture(textureImage, vec2(vUv.x, animationFrameHeight * (vUv.y + animationFrameIndex))); + if (animationInterpolation > 0.0) { + color = mix(color, texture(textureImage, vec2(vUv.x, animationFrameHeight * (vUv.y + animationInterpolationFrameIndex))), animationInterpolation); + } + if (color.a <= 0.01) discard; //apply vertex-color diff --git a/BlueMapCommon/webapp/src/js/util/Utils.js b/BlueMapCommon/webapp/src/js/util/Utils.js index b588c0c7..dde61d2d 100644 --- a/BlueMapCommon/webapp/src/js/util/Utils.js +++ b/BlueMapCommon/webapp/src/js/util/Utils.js @@ -33,7 +33,7 @@ export const VEC3_Z = new Vector3(0, 0, 1); /** * Converts a url-encoded image string to an actual image-element * @param string {string} - * @returns {HTMLElement} + * @returns {HTMLImageElement} */ export const stringToImage = string => { let image = document.createElementNS('http://www.w3.org/1999/xhtml', 'img'); diff --git a/BlueMapCore/src/main/java/de/bluecolored/bluemap/core/resources/resourcepack/ResourcePack.java b/BlueMapCore/src/main/java/de/bluecolored/bluemap/core/resources/resourcepack/ResourcePack.java index e992ff5a..06df4809 100644 --- a/BlueMapCore/src/main/java/de/bluecolored/bluemap/core/resources/resourcepack/ResourcePack.java +++ b/BlueMapCore/src/main/java/de/bluecolored/bluemap/core/resources/resourcepack/ResourcePack.java @@ -39,6 +39,7 @@ import de.bluecolored.bluemap.core.resources.biome.BiomeConfig; import de.bluecolored.bluemap.core.resources.resourcepack.blockmodel.BlockModel; import de.bluecolored.bluemap.core.resources.resourcepack.blockmodel.TextureVariable; import de.bluecolored.bluemap.core.resources.resourcepack.blockstate.BlockState; +import de.bluecolored.bluemap.core.resources.resourcepack.texture.AnimationMeta; import de.bluecolored.bluemap.core.resources.resourcepack.texture.Texture; import de.bluecolored.bluemap.core.util.Tristate; import de.bluecolored.bluemap.core.world.Biome; @@ -50,6 +51,8 @@ import java.awt.image.BufferedImage; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; +import java.io.Reader; +import java.nio.charset.StandardCharsets; import java.nio.file.FileSystem; import java.nio.file.FileSystems; import java.nio.file.Files; @@ -381,9 +384,23 @@ public class ResourcePack { ResourcePath resourcePath = new ResourcePath<>(root.relativize(file)); if (!usedTextures.contains(resourcePath)) return null; // don't load unused textures + // load image + BufferedImage image; try (InputStream in = Files.newInputStream(file)) { - return Texture.from(resourcePath, ImageIO.read(in), Files.exists(file.resolveSibling(file.getFileName() + ".mcmeta"))); + image = ImageIO.read(in); } + + // load animation + AnimationMeta animation = null; + Path animationPathFile = file.resolveSibling(file.getFileName() + ".mcmeta"); + if (Files.exists(animationPathFile)) { + try (Reader in = Files.newBufferedReader(animationPathFile, StandardCharsets.UTF_8)) { + animation = ResourcesGson.INSTANCE.fromJson(in, AnimationMeta.class); + } + } + + return Texture.from(resourcePath, image, animation); + }, textures)); } catch (RuntimeException ex) { diff --git a/BlueMapCore/src/main/java/de/bluecolored/bluemap/core/resources/resourcepack/texture/AnimationMeta.java b/BlueMapCore/src/main/java/de/bluecolored/bluemap/core/resources/resourcepack/texture/AnimationMeta.java new file mode 100644 index 00000000..1c97eea5 --- /dev/null +++ b/BlueMapCore/src/main/java/de/bluecolored/bluemap/core/resources/resourcepack/texture/AnimationMeta.java @@ -0,0 +1,113 @@ +package de.bluecolored.bluemap.core.resources.resourcepack.texture; + +import com.google.gson.Gson; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; +import de.bluecolored.bluemap.core.resources.AbstractTypeAdapterFactory; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@Getter +@JsonAdapter(AnimationMeta.Adapter.class) +public class AnimationMeta { + + private boolean interpolate = false; + private int width = 1; + private int height = 1; + private int frametime = 1; + + @Nullable private List frames = null; + + @Getter + @AllArgsConstructor + public static class FrameMeta { + private int index; + private int time; + } + + static class Adapter extends AbstractTypeAdapterFactory { + + public Adapter() { + super(AnimationMeta.class); + } + + @Override + public AnimationMeta read(JsonReader in, Gson gson) throws IOException { + AnimationMeta animationMeta = new AnimationMeta(); + + in.beginObject(); + while (in.hasNext()) { + if (!in.nextName().equals("animation")){ + in.skipValue(); + continue; + } + + in.beginObject(); + while (in.hasNext()) { + switch (in.nextName()) { + case "interpolate" : animationMeta.interpolate = in.nextBoolean(); break; + case "width" : animationMeta.width = in.nextInt(); break; + case "height" : animationMeta.height = in.nextInt(); break; + case "frametime" : animationMeta.frametime = in.nextInt(); break; + case "frames" : readFramesList(in, animationMeta); break; + default: in.skipValue(); break; + } + } + in.endObject(); + + } + in.endObject(); + + // default frame-time + if (animationMeta.frames != null) { + for (FrameMeta frameMeta : animationMeta.frames) { + if (frameMeta.time == -1) frameMeta.time = animationMeta.frametime; + } + } + + return animationMeta; + } + + private void readFramesList(JsonReader in, AnimationMeta animationMeta) throws IOException { + animationMeta.frames = new ArrayList<>(); + + in.beginArray(); + while (in.hasNext()) { + int index = 0; + int time = -1; + + if (in.peek() == JsonToken.NUMBER) { + index = in.nextInt(); + } else { + in.beginObject(); + while (in.hasNext()) { + switch (in.nextName()) { + case "index" : index = in.nextInt(); break; + case "time" : time = in.nextInt(); break; + default: in.skipValue(); break; + } + } + in.endObject(); + } + + animationMeta.frames.add(new FrameMeta(index, time)); + } + in.endArray(); + } + + @Override + public void write(JsonWriter out, AnimationMeta value, Gson gson) throws IOException { + gson.getDelegateAdapter(this, TypeToken.get(AnimationMeta.class)).write(out, value); + } + + } + +} diff --git a/BlueMapCore/src/main/java/de/bluecolored/bluemap/core/resources/resourcepack/texture/Texture.java b/BlueMapCore/src/main/java/de/bluecolored/bluemap/core/resources/resourcepack/texture/Texture.java index 6acb696f..0e5c58f3 100644 --- a/BlueMapCore/src/main/java/de/bluecolored/bluemap/core/resources/resourcepack/texture/Texture.java +++ b/BlueMapCore/src/main/java/de/bluecolored/bluemap/core/resources/resourcepack/texture/Texture.java @@ -28,6 +28,7 @@ import de.bluecolored.bluemap.api.debug.DebugDump; import de.bluecolored.bluemap.core.resources.ResourcePath; import de.bluecolored.bluemap.core.util.BufferedImageUtil; import de.bluecolored.bluemap.core.util.math.Color; +import org.jetbrains.annotations.Nullable; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; @@ -42,24 +43,27 @@ public class Texture { new ResourcePath<>("bluemap", "missing"), new Color().set(0.5f, 0f, 0.5f, 1.0f, false), false, - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAPklEQVR4Xu3MsQkAMAwDQe2/tFPnBB4gpLhG8MpkZpNkZ6AKZKAKZKAKZKAKZKAKZKAKZKAKWg0XD/UPnjg4MbX+EDdeTUwAAAAASUVORK5CYII\u003d" + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAPklEQVR4Xu3MsQkAMAwDQe2/tFPnBB4gpLhG8MpkZpNkZ6AKZKAKZKAKZKAKZKAKZKAKZKAKWg0XD/UPnjg4MbX+EDdeTUwAAAAASUVORK5CYII\u003d", + null ); private ResourcePath resourcePath; private Color color; private boolean halfTransparent; private String texture; + @Nullable private AnimationMeta animation; private transient Color colorPremultiplied; @SuppressWarnings("unused") private Texture() {} - private Texture(ResourcePath resourcePath, Color color, boolean halfTransparent, String texture) { + private Texture(ResourcePath resourcePath, Color color, boolean halfTransparent, String texture, @Nullable AnimationMeta animation) { this.resourcePath = resourcePath; this.color = color.straight(); this.halfTransparent = halfTransparent; this.texture = texture; + this.animation = animation; } private Texture(ResourcePath resourcePath) { @@ -67,6 +71,7 @@ public class Texture { this.color = MISSING.color; this.halfTransparent = MISSING.halfTransparent; this.texture = MISSING.texture; + this.animation = null; } public ResourcePath getResourcePath() { @@ -95,19 +100,7 @@ public class Texture { return texture; } - public void unloadImageData() { - texture = null; - } - - public static Texture from(ResourcePath resourcePath, BufferedImage image) throws IOException { - return from(resourcePath, image, true); - } - - public static Texture from(ResourcePath resourcePath, BufferedImage image, boolean animated) throws IOException { - //crop off animation frames - if (animated && image.getHeight() > image.getWidth()){ - image = image.getSubimage(0, 0, image.getWidth(), image.getWidth()); - } + public static Texture from(ResourcePath resourcePath, BufferedImage image, @Nullable AnimationMeta animation) throws IOException { //check halfTransparency boolean halfTransparent = checkHalfTransparent(image); @@ -120,7 +113,7 @@ public class Texture { ImageIO.write(image, "png", os); String base64 = "data:image/png;base64," + Base64.getEncoder().encodeToString(os.toByteArray()); - return new Texture(resourcePath, color, halfTransparent, base64); + return new Texture(resourcePath, color, halfTransparent, base64, animation); } private static boolean checkHalfTransparent(BufferedImage image){