/*
* 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;
}
}