/* * Copyright 2013 MovingBlocks * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.terasology.world.block.tiles; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.common.collect.Queues; import com.google.common.math.IntMath; import de.matthiasmann.twl.utils.PNGDecoder; import gnu.trove.map.TObjectIntMap; import gnu.trove.map.hash.TObjectIntHashMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.terasology.assets.ResourceUrn; import org.terasology.engine.paths.PathManager; import org.terasology.math.TeraMath; import org.terasology.math.geom.Rect2f; import org.terasology.math.geom.Vector2f; import org.terasology.naming.Name; import org.terasology.rendering.assets.atlas.Atlas; import org.terasology.rendering.assets.atlas.AtlasData; import org.terasology.rendering.assets.material.Material; import org.terasology.rendering.assets.material.MaterialData; import org.terasology.rendering.assets.texture.Texture; import org.terasology.rendering.assets.texture.TextureData; import org.terasology.rendering.assets.texture.subtexture.SubtextureData; import org.terasology.utilities.Assets; import javax.imageio.ImageIO; import java.awt.*; import java.awt.image.BufferedImage; import java.io.BufferedOutputStream; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.nio.ByteBuffer; import java.nio.file.Files; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.BlockingQueue; import java.util.function.Consumer; /** */ public class WorldAtlasImpl implements WorldAtlas { private static final Logger logger = LoggerFactory.getLogger(WorldAtlasImpl.class); private static final int MAX_TILES = 65536; private static final Color UNIT_Z_COLOR = new Color(0.5f, 0.5f, 1.0f, 1.0f); private static final Color TRANSPARENT_COLOR = new Color(0.0f, 0.0f, 0.0f, 0.0f); private static final Color BLACK_COLOR = new Color(0.0f, 0.0f, 0.0f, 1.0f); private int maxAtlasSize = 4096; private int atlasSize = 256; private int tileSize = 16; private TObjectIntMap<ResourceUrn> tileIndexes = new TObjectIntHashMap<>(); private List<BlockTile> tiles = Lists.newArrayList(); private List<BlockTile> tilesNormal = Lists.newArrayList(); private List<BlockTile> tilesHeight = Lists.newArrayList(); private List<BlockTile> tilesGloss = Lists.newArrayList(); private BlockingQueue<BlockTile> reloadQueue = Queues.newLinkedBlockingQueue(); private Consumer<BlockTile> tileReloadListener = reloadQueue::add; /** * @param maxAtlasSize The maximum dimensions of the atlas (both width and height, in pixels) */ public WorldAtlasImpl(int maxAtlasSize) { this.maxAtlasSize = maxAtlasSize; Assets.list(BlockTile.class).forEach(this::indexTile); buildAtlas(); } @Override public int getTileSize() { return tileSize; } @Override public int getAtlasSize() { return atlasSize; } @Override public float getRelativeTileSize() { return ((float) getTileSize()) / (float) getAtlasSize(); } @Override public int getNumMipmaps() { return TeraMath.sizeOfPower(tileSize) + 1; } @Override public Vector2f getTexCoords(BlockTile tile, boolean warnOnError) { return getTexCoords(tile.getUrn(), warnOnError); } /** * Obtains the tex coords of a block tile. If it isn't part of the atlas it is added to the atlas. * * @param uri The uri of the block tile of interest. * @param warnOnError Whether a warning should be logged if the asset canot be found * @return The tex coords of the tile in the atlas. */ @Override public Vector2f getTexCoords(ResourceUrn uri, boolean warnOnError) { return getTexCoords(getTileIndex(uri, warnOnError)); } @Override public void update() { if (!reloadQueue.isEmpty()) { List<BlockTile> reloadList = Lists.newArrayListWithExpectedSize(reloadQueue.size()); reloadQueue.drainTo(reloadList); // TODO: does this need to be more efficient? could just reload individual block tile locations. buildAtlas(); } } @Override public void dispose() { for (BlockTile tile : tiles) { tile.unsubscribe(tileReloadListener); } } private Vector2f getTexCoords(int id) { int tilesPerDim = atlasSize / tileSize; return new Vector2f((id % tilesPerDim) * getRelativeTileSize(), (id / tilesPerDim) * getRelativeTileSize()); } private int getTileIndex(ResourceUrn uri, boolean warnOnError) { if (tileIndexes.containsKey(uri)) { return tileIndexes.get(uri); } if (warnOnError) { logger.warn("Tile {} could not be resolved", uri); } return 0; } private int indexTile(ResourceUrn uri) { if (tiles.size() == MAX_TILES) { logger.error("Maximum tiles exceeded"); return 0; } Optional<BlockTile> tile = Assets.get(uri, BlockTile.class); if (tile.isPresent()) { if (checkTile(tile.get())) { int index = tiles.size(); tiles.add(tile.get()); addNormal(uri); addHeightMap(uri); addGlossMap(uri); tileIndexes.put(uri, index); tile.get().subscribe(tileReloadListener); return index; } else { logger.error("Invalid tile {}, must be a square with power-of-two sides.", uri); return 0; } } return 0; } private boolean checkTile(BlockTile tile) { return tile.getImage().getWidth() == tile.getImage().getHeight() && IntMath.isPowerOfTwo(tile.getImage().getWidth()); } private void addNormal(ResourceUrn uri) { String name = uri.toString() + "Normal"; Optional<BlockTile> tile = Assets.get(name, BlockTile.class); if (tile.isPresent()) { tilesNormal.add(tile.get()); } else { // intentionally pad this list with null so that the indexes match the main atlas tilesNormal.add(null); } } private void addHeightMap(ResourceUrn uri) { String name = uri.toString() + "Height"; Optional<BlockTile> tile = Assets.get(name, BlockTile.class); if (tile.isPresent()) { tilesHeight.add(tile.get()); } else { // intentionally pad this list with null so that the indexes match the main atlas tilesHeight.add(null); } } private void addGlossMap(ResourceUrn uri) { String name = uri.toString() + "Gloss"; Optional<BlockTile> tile = Assets.get(name, BlockTile.class); if (tile.isPresent()) { tilesGloss.add(tile.get()); } else { // intentionally pad this list with null so that the indexes match the main atlas tilesGloss.add(null); } } private void buildAtlas() { calculateAtlasSizes(); int numMipMaps = getNumMipmaps(); ByteBuffer[] data = createAtlasMipmaps(numMipMaps, TRANSPARENT_COLOR, tiles, "tiles.png"); ByteBuffer[] dataNormal = createAtlasMipmaps(numMipMaps, UNIT_Z_COLOR, tilesNormal, "tilesNormal.png", tilesGloss); ByteBuffer[] dataHeight = createAtlasMipmaps(numMipMaps, BLACK_COLOR, tilesHeight, "tilesHeight.png"); TextureData terrainTexData = new TextureData(atlasSize, atlasSize, data, Texture.WrapMode.CLAMP, Texture.FilterMode.NEAREST); Texture terrainTex = Assets.generateAsset(new ResourceUrn("engine:terrain"), terrainTexData, Texture.class); TextureData terrainNormalData = new TextureData(atlasSize, atlasSize, dataNormal, Texture.WrapMode.CLAMP, Texture.FilterMode.NEAREST); Assets.generateAsset(new ResourceUrn("engine:terrainNormal"), terrainNormalData, Texture.class); TextureData terrainHeightData = new TextureData(atlasSize, atlasSize, dataHeight, Texture.WrapMode.CLAMP, Texture.FilterMode.NEAREST); Assets.generateAsset(new ResourceUrn("engine:terrainHeight"), terrainHeightData, Texture.class); MaterialData terrainMatData = new MaterialData(Assets.getShader("engine:block").get()); terrainMatData.setParam("textureAtlas", terrainTex); terrainMatData.setParam("colorOffset", new float[]{1, 1, 1}); terrainMatData.setParam("textured", true); Assets.generateAsset(new ResourceUrn("engine:terrain"), terrainMatData, Material.class); createTextureAtlas(terrainTex); } private void createTextureAtlas(final Texture texture) { final Map<Name, Map<Name, SubtextureData>> textureAtlases = Maps.newHashMap(); final Vector2f texSize = new Vector2f(getRelativeTileSize(), getRelativeTileSize()); tileIndexes.forEachEntry((tileUri, index) -> { Vector2f coords = getTexCoords(index); SubtextureData subtextureData = new SubtextureData(texture, Rect2f.createFromMinAndSize(coords, texSize)); Map<Name, SubtextureData> textureAtlas = textureAtlases.get(tileUri.getModuleName()); if (textureAtlas == null) { textureAtlas = Maps.newHashMap(); textureAtlases.put(tileUri.getModuleName(), textureAtlas); } textureAtlas.put(tileUri.getResourceName(), subtextureData); return true; }); for (Map.Entry<Name, Map<Name, SubtextureData>> atlas : textureAtlases.entrySet()) { AtlasData data = new AtlasData(atlas.getValue()); Assets.generateAsset(new ResourceUrn(atlas.getKey(), new Name("terrain")), data, Atlas.class); } } private ByteBuffer[] createAtlasMipmaps(int numMipMaps, Color initialColor, List<BlockTile> tileImages, String screenshotName) { return createAtlasMipmaps(numMipMaps, initialColor, tileImages, screenshotName, Lists.newArrayList()); } private ByteBuffer[] createAtlasMipmaps(int numMipMaps, Color initialColor, List<BlockTile> tileImages, String screenshotName, List<BlockTile> alphaMaskTiles) { ByteBuffer[] data = new ByteBuffer[numMipMaps]; for (int i = 0; i < numMipMaps; ++i) { BufferedImage image = generateAtlas(i, tileImages, initialColor); if (alphaMaskTiles.size() > 0) { BufferedImage alphaMask = generateAtlas(i, alphaMaskTiles, Color.BLACK); storeGreyscaleMapIntoAlpha(image, alphaMask); } if (i == 0) { try (OutputStream stream = new BufferedOutputStream(Files.newOutputStream(PathManager.getInstance().getScreenshotPath().resolve(screenshotName)))) { ImageIO.write(image, "png", stream); } catch (IOException e) { logger.warn("Failed to write atlas"); } } try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) { ImageIO.write(image, "png", bos); PNGDecoder decoder = new PNGDecoder(new ByteArrayInputStream(bos.toByteArray())); ByteBuffer buf = ByteBuffer.allocateDirect(4 * decoder.getWidth() * decoder.getHeight()); decoder.decode(buf, decoder.getWidth() * 4, PNGDecoder.Format.RGBA); buf.flip(); data[i] = buf; } catch (IOException e) { logger.error("Failed to create atlas texture"); } } return data; } // Ref: http://stackoverflow.com/questions/221830/set-bufferedimage-alpha-mask-in-java/8058442#8058442 public void storeGreyscaleMapIntoAlpha(BufferedImage imageWithoutAlpha, BufferedImage greyscaleImage) { int width = imageWithoutAlpha.getWidth(); int height = imageWithoutAlpha.getHeight(); int[] imagePixels = imageWithoutAlpha.getRGB(0, 0, width, height, null, 0, width); int[] maskPixels = greyscaleImage.getRGB(0, 0, width, height, null, 0, width); for (int i = 0; i < imagePixels.length; i++) { int color = imagePixels[i] & 0x00ffffff; // Mask preexisting alpha int alpha = maskPixels[i] << 24; // Shift blue to alpha imagePixels[i] = color | alpha; } imageWithoutAlpha.setRGB(0, 0, width, height, imagePixels, 0, width); } // The atlas is configured using the following constraints... // 1. The overall tile size is the size of the largest tile loaded // 2. The atlas will never be larger than 4096*4096 px // 3. The tile size gets adjusted if the tiles won't fit into the atlas using the overall tile size // (the tile size gets halved until all tiles will fit into the atlas) // 4. The size of the atlas is always a power of two - as is the tile size private void calculateAtlasSizes() { tileSize = 16; tiles.stream().filter(tile -> tile.getImage().getWidth() > tileSize).forEach(tile -> tileSize = tile.getImage().getWidth()); atlasSize = 1; while (atlasSize * atlasSize < tiles.size()) { atlasSize *= 2; } atlasSize = atlasSize * tileSize; if (atlasSize > maxAtlasSize) { atlasSize = maxAtlasSize; int maxTiles = (atlasSize / tileSize) * (atlasSize / tileSize); while (maxTiles < tiles.size()) { tileSize >>= 1; maxTiles = (atlasSize / tileSize) * (atlasSize / tileSize); } } } private BufferedImage generateAtlas(int mipMapLevel, List<BlockTile> tileImages, Color clearColor) { int size = atlasSize / (1 << mipMapLevel); int textureSize = tileSize / (1 << mipMapLevel); int tilesPerDim = atlasSize / tileSize; BufferedImage result = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); Graphics g = result.getGraphics(); g.setColor(clearColor); for (int index = 0; index < tileImages.size(); ++index) { int posX = (index) % tilesPerDim; int posY = (index) / tilesPerDim; BlockTile tile = tileImages.get(index); if (tile != null) { g.drawImage(tile.getImage().getScaledInstance(textureSize, textureSize, Image.SCALE_SMOOTH), posX * textureSize, posY * textureSize, null); } else { g.fillRect(posX * textureSize, posY * textureSize, textureSize, textureSize); } } return result; } }