diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/Ext.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/Ext.kt
new file mode 100644
index 00000000..86d2162d
--- /dev/null
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/Ext.kt
@@ -0,0 +1,8 @@
+package ru.dbotthepony.kstarbound
+
+import com.google.gson.GsonBuilder
+import com.google.gson.TypeAdapter
+
+inline fun <reified T> GsonBuilder.registerTypeAdapter(adapter: TypeAdapter<T>): GsonBuilder {
+	return registerTypeAdapter(T::class.java, adapter)
+}
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/Main.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/Main.kt
index 0a749737..91deb112 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/Main.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/Main.kt
@@ -12,6 +12,7 @@ import java.io.ByteArrayInputStream
 import java.io.DataInputStream
 import java.io.File
 import java.util.zip.Inflater
+import kotlin.math.roundToInt
 
 private val LOGGER = LogManager.getLogger()
 
@@ -75,12 +76,17 @@ fun main() {
 								hitTile = true
 							}
 
-							reader.skipBytes(1) // Foreground hue shift
-							reader.skipBytes(1) // Foreground color variant
+							// reader.skipBytes(1) // Foreground hue shift
+							// reader.skipBytes(1) // Foreground color variant
+							val colorShift = reader.readUnsignedByte()
+							val colorVariant = reader.readUnsignedByte()
 
 							val modifier = reader.readUnsignedShort()
 							val getModifier = Starbound.tileModifiersByIDAccess[modifier]
 
+							chunk.foreground[x, y]?.color = colorVariant
+							chunk.foreground[x, y]?.setHueShift(colorShift)
+
 							if (getModifier != null && getMat != null) {
 								chunk.foreground[x, y]?.modifier = getModifier
 							}
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/Starbound.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/Starbound.kt
index d5e1695a..9dc3fe94 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/Starbound.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/Starbound.kt
@@ -8,6 +8,7 @@ import ru.dbotthepony.kstarbound.api.PhysicalFS
 import ru.dbotthepony.kstarbound.api.getPathFilename
 import ru.dbotthepony.kstarbound.api.getPathFolder
 import ru.dbotthepony.kstarbound.defs.*
+import ru.dbotthepony.kstarbound.defs.liquid.LiquidDefinition
 import ru.dbotthepony.kstarbound.defs.projectile.*
 import ru.dbotthepony.kstarbound.defs.tile.MaterialModifier
 import ru.dbotthepony.kstarbound.defs.tile.RenderParameters
@@ -57,10 +58,15 @@ object Starbound : IVFS {
 	private val tileModifiers = HashMap<String, MaterialModifier>()
 	private val tileModifiersByID = Int2ObjectAVLTreeMap<MaterialModifier>()
 
+	private val liquid = HashMap<String, LiquidDefinition>()
+	private val liquidByID = Int2ObjectAVLTreeMap<LiquidDefinition>()
+
 	private val projectiles = HashMap<String, ConfiguredProjectile>()
 	private val parallax = HashMap<String, ParallaxPrototype>()
 	private val functions = HashMap<String, JsonFunction>()
 
+	val liquidAccess: Map<String, LiquidDefinition> = Collections.unmodifiableMap(liquid)
+	val liquidByIDAccess: Map<Int, LiquidDefinition> = Collections.unmodifiableMap(liquidByID)
 	val tileModifiersAccess: Map<String, MaterialModifier> = Collections.unmodifiableMap(tileModifiers)
 	val tileModifiersByIDAccess: Map<Int, MaterialModifier> = Collections.unmodifiableMap(tileModifiersByID)
 	val tilesAccess: Map<String, TileDefinition> = Collections.unmodifiableMap(tiles)
@@ -94,6 +100,7 @@ object Starbound : IVFS {
 		.also(RenderParameters::registerGson)
 		.also(RenderTemplate::registerGson)
 		.also(TileDefinition::registerGson)
+		.also(LiquidDefinition::registerGson)
 
 		.registerTypeAdapter(DamageType::class.java, CustomEnumTypeAdapter(DamageType.values()).nullSafe())
 
@@ -183,6 +190,7 @@ object Starbound : IVFS {
 			loadStage(callback, this::loadProjectiles, "projectiles")
 			loadStage(callback, this::loadParallax, "parallax definitions")
 			loadStage(callback, this::loadMaterialModifiers, "material modifier definitions")
+			loadStage(callback, this::loadLiquidDefinitions, "liquid definitions")
 
 			initializing = false
 			initialized = true
@@ -350,4 +358,25 @@ object Starbound : IVFS {
 
 		readingFolder = null
 	}
+
+	private fun loadLiquidDefinitions(callback: (String) -> Unit) {
+		for (fs in fileSystems) {
+			for (listedFile in fs.listAllFilesWithExtension("liquid")) {
+				try {
+					callback("Loading $listedFile")
+
+					readingFolder = getPathFolder(listedFile)
+					val liquidDef = gson.fromJson(getReader(listedFile), LiquidDefinition::class.java)
+
+					check(liquid.put(liquidDef.name, liquidDef) == null) { "Already has liquid with name ${liquidDef.name} loaded!" }
+					check(liquidByID.put(liquidDef.liquidId, liquidDef) == null) { "Already has liquid with ID ${liquidDef.liquidId} loaded!" }
+				} catch (err: Throwable) {
+					//throw TileDefLoadingException("Loading tile file $listedFile", err)
+					LOGGER.error("Loading liquid definition file $listedFile", err)
+				}
+			}
+		}
+
+		readingFolder = null
+	}
 }
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/client/ClientChunk.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/client/ClientChunk.kt
index b5792416..04ba733b 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/client/ClientChunk.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/client/ClientChunk.kt
@@ -47,12 +47,12 @@ class ClientChunk(world: ClientWorld, pos: ChunkPos) : Chunk<ClientWorld, Client
 
 			for ((pos, tile) in view.posToTile) {
 				if (tile != null) {
-					state.tileRenderers.getTileRenderer(tile.def.materialName).tesselate(view, layers, pos, background = isBackground)
+					state.tileRenderers.getTileRenderer(tile.def.materialName).tesselate(tile, view, layers, pos, background = isBackground)
 
 					val modifier = tile.modifier
 
 					if (modifier != null) {
-						state.tileRenderers.getModifierRenderer(modifier.modName).tesselate(view, layers, pos, background = isBackground)
+						state.tileRenderers.getModifierRenderer(modifier.modName).tesselate(tile, view, layers, pos, background = isBackground, isModifier = true)
 					}
 				}
 			}
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/client/gl/GLAttributeList.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/client/gl/GLAttributeList.kt
index 974c246a..ba85fb90 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/client/gl/GLAttributeList.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/client/gl/GLAttributeList.kt
@@ -23,6 +23,9 @@ enum class GLType(val identity: Int, val typeIndentity: Int, val byteSize: Int,
 }
 
 interface IGLAttributeList {
+	/**
+	 * Применяет список атрибутов к заданному [GLVertexArrayObject] (попутно включая или отключая их через [enable])
+	 */
 	fun apply(target: GLVertexArrayObject, enable: Boolean = false)
 }
 
@@ -85,10 +88,15 @@ class GLFlatAttributeList(builder: GLFlatAttributeListBuilder) : IGLAttributeLis
 	}
 
 	companion object {
-		val VEC2F = GLFlatAttributeListBuilder().also {it.push(GLType.VEC2F)}.build()
-		val VEC3F = GLFlatAttributeListBuilder().also {it.push(GLType.VEC3F)}.build()
-		val VERTEX_TEXTURE = GLFlatAttributeListBuilder().also {it.push(GLType.VEC3F).push(GLType.VEC2F)}.build()
-		val VERTEX_2D_TEXTURE = GLFlatAttributeListBuilder().also {it.push(GLType.VEC2F).push(GLType.VEC2F)}.build()
+		val VEC2F = GLFlatAttributeListBuilder().push(GLType.VEC2F).build()
+		val VEC3F = GLFlatAttributeListBuilder().push(GLType.VEC3F).build()
+
+		val VERTEX_TEXTURE = GLFlatAttributeListBuilder().push(GLType.VEC3F).push(GLType.VEC2F).build()
+
+		val VERTEX_HSV_TEXTURE = GLFlatAttributeListBuilder()
+			.push(GLType.VEC3F, GLType.VEC2F, GLType.FLOAT).build()
+
+		val VERTEX_2D_TEXTURE = GLFlatAttributeListBuilder().push(GLType.VEC2F).push(GLType.VEC2F).build()
 	}
 }
 
@@ -109,6 +117,11 @@ class GLFlatAttributeListBuilder : IGLAttributeList {
 		return push("$type#${attributes.size}", type)
 	}
 
+	fun push(vararg types: GLType): GLFlatAttributeListBuilder {
+		for (type in types) push(type)
+		return this
+	}
+
 	fun push(name: String, type: GLType): GLFlatAttributeListBuilder {
 		check(!findName(name)) { "Already has named attribute $name!" }
 		attributes.add(name to type)
@@ -117,7 +130,7 @@ class GLFlatAttributeListBuilder : IGLAttributeList {
 
 	fun build() = GLFlatAttributeList(this)
 
-	@Deprecated("Используй build()")
+	@Deprecated("Используй build()", replaceWith = ReplaceWith("build()"))
 	override fun apply(target: GLVertexArrayObject, enable: Boolean) {
 		var offset = 0L
 		var stride = 0
@@ -136,9 +149,4 @@ class GLFlatAttributeListBuilder : IGLAttributeList {
 			}
 		}
 	}
-
-	companion object {
-		val VEC3F = GLFlatAttributeList.VEC3F
-		val VERTEX_TEXTURE = GLFlatAttributeList.VERTEX_TEXTURE
-	}
 }
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/client/gl/GLStateTracker.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/client/gl/GLStateTracker.kt
index 620b6a2e..0b2ab8bd 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/client/gl/GLStateTracker.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/client/gl/GLStateTracker.kt
@@ -227,7 +227,7 @@ class GLStateTracker {
 		checkForGLError()
 	}
 
-	val thread = Thread.currentThread()
+	val thread: Thread = Thread.currentThread()
 	val tileRenderers = TileRenderers(this)
 
 	fun ensureSameThread() {
@@ -356,18 +356,25 @@ class GLStateTracker {
 
 	val shaderVertexTexture: GLTransformableProgram
 	val shaderVertexTextureColor: GLTransformableColorableProgram
+	val shaderVertexTextureHSVColor: GLTransformableColorableProgram
 
 	init {
 		val textureF = GLShader.internalFragment("shaders/fragment/texture.glsl")
 		val textureColorF = GLShader.internalFragment("shaders/fragment/texture_color.glsl")
 		val textureV = GLShader.internalVertex("shaders/vertex/texture.glsl")
 
+		val textureFragmentHSV = GLShader.internalFragment("shaders/fragment/texture_color_per_vertex.glsl")
+		val textureVertexHSV = GLShader.internalVertex("shaders/vertex/texture_hsv.glsl")
+
 		shaderVertexTexture = GLTransformableProgram(this, textureF, textureV)
 		shaderVertexTextureColor = GLTransformableColorableProgram(this, textureColorF, textureV)
+		shaderVertexTextureHSVColor = GLTransformableColorableProgram(this, textureFragmentHSV, textureVertexHSV)
 
 		textureF.unlink()
 		textureColorF.unlink()
 		textureV.unlink()
+		textureFragmentHSV.unlink()
+		textureVertexHSV.unlink()
 	}
 
 	val fontProgram: GLTransformableColorableProgram
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/client/gl/VertexBuilder.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/client/gl/VertexBuilder.kt
index 04ade50b..f9c5933d 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/client/gl/VertexBuilder.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/client/gl/VertexBuilder.kt
@@ -125,6 +125,7 @@ interface IVertex<This : IVertex<This, VertexBuilderType>, VertexBuilderType> {
 	fun expect(type: GLType): This
 	fun pushVec3f(x: Float, y: Float, z: Float): This
 	fun pushVec2f(x: Float, y: Float): This
+	fun push(value: Float): This
 	fun end(): VertexBuilderType
 }
 
@@ -132,6 +133,20 @@ typealias VertexTransformer = (IVertex<*, *>, Int) -> IVertex<*, *>
 private val emptyTransform: VertexTransformer = { it, _ -> it }
 private val EMPTY_BUFFER = ByteBuffer.allocateDirect(0)
 
+fun VertexTransformer.before(other: VertexTransformer): VertexTransformer {
+	return { a, b ->
+		other.invoke(a, b)
+		this.invoke(a, b)
+	}
+}
+
+fun VertexTransformer.after(other: VertexTransformer): VertexTransformer {
+	return { a, b ->
+		this.invoke(a, b)
+		other.invoke(a, b)
+	}
+}
+
 object VertexTransformers {
 	fun uv(u0: Float,
 		   v0: Float,
@@ -279,6 +294,7 @@ class DynamicVertexBuilder(val attributes: GLFlatAttributeList, override val typ
 					is IntArray -> for (i in element) bytes.putInt(i)
 					is ByteArray -> for (i in element) bytes.put(i)
 					is DoubleArray -> for (i in element) bytes.putDouble(i)
+					is Float -> bytes.putFloat(element)
 					else -> throw IllegalStateException("Unknown element $element")
 				}
 			}
@@ -291,6 +307,7 @@ class DynamicVertexBuilder(val attributes: GLFlatAttributeList, override val typ
 					is IntArray -> it.joinToString(", ")
 					is ByteArray -> it.joinToString(", ")
 					is DoubleArray -> it.joinToString(", ")
+					is Float -> it
 					else -> "null"
 				} }.joinToString("; ")})"
 		}
@@ -331,6 +348,12 @@ class DynamicVertexBuilder(val attributes: GLFlatAttributeList, override val typ
 			return this
 		}
 
+		override fun push(value: Float): Vertex {
+			expect(GLType.FLOAT)
+			store[index++] = value
+			return this
+		}
+
 		override fun checkValid() {
 			for (elem in store.indices) {
 				if (store[elem] == null) {
@@ -521,6 +544,15 @@ class StreamVertexBuilder(
 			return this
 		}
 
+		override fun push(value: Float): Vertex {
+			expect(GLType.FLOAT)
+			vertexBuffer.position(bufferPosition)
+			vertexBuffer.putFloat(value)
+			index++
+			bufferPosition += 4
+			return this
+		}
+
 		override fun end(): StreamVertexBuilder {
 			check(index == attributes.size) { "Vertex $vertexIndex is not fully filled (only $index attributes provided, ${attributes.size} required)" }
 			return this@StreamVertexBuilder
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/client/render/TileRenderer.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/client/render/TileRenderer.kt
index 039aeb9b..a1cacabf 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/client/render/TileRenderer.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/client/render/TileRenderer.kt
@@ -8,7 +8,7 @@ import ru.dbotthepony.kstarbound.PIXELS_IN_STARBOUND_UNITf
 import ru.dbotthepony.kstarbound.Starbound
 import ru.dbotthepony.kstarbound.client.gl.*
 import ru.dbotthepony.kstarbound.defs.tile.*
-import ru.dbotthepony.kstarbound.world.ChunkTile
+import ru.dbotthepony.kstarbound.world.TileState
 import ru.dbotthepony.kstarbound.world.ITileChunk
 import ru.dbotthepony.kvector.vector.Color
 import ru.dbotthepony.kvector.vector.nint.Vector2i
@@ -80,7 +80,7 @@ class TileRenderers(val state: GLStateTracker) {
 		}
 	}
 
-	private inner class ForegroundTileProgram(private val texture: GLTexture2D) : ConfiguredShaderProgram(state.shaderVertexTexture) {
+	private inner class ForegroundTileProgram(private val texture: GLTexture2D) : ConfiguredShaderProgram(state.shaderVertexTextureHSVColor) {
 		override fun setup() {
 			super.setup()
 			state.activeTexture = 0
@@ -89,6 +89,8 @@ class TileRenderers(val state: GLStateTracker) {
 			texture.bind()
 			texture.textureMagFilter = GL_NEAREST
 			texture.textureMinFilter = GL_NEAREST
+
+			program["_color"] = FOREGROUND_COLOR
 		}
 
 		override fun equals(other: Any?): Boolean {
@@ -108,7 +110,7 @@ class TileRenderers(val state: GLStateTracker) {
 		}
 	}
 
-	private inner class BackgroundTileProgram(private val texture: GLTexture2D) : ConfiguredShaderProgram(state.shaderVertexTextureColor) {
+	private inner class BackgroundTileProgram(private val texture: GLTexture2D) : ConfiguredShaderProgram(state.shaderVertexTextureHSVColor) {
 		override fun setup() {
 			super.setup()
 			state.activeTexture = 0
@@ -154,6 +156,7 @@ class TileRenderers(val state: GLStateTracker) {
 
 	companion object {
 		val BACKGROUND_COLOR = Color(0.4f, 0.4f, 0.4f)
+		val FOREGROUND_COLOR = Color(1f, 1f, 1f)
 	}
 }
 
@@ -163,16 +166,16 @@ private enum class TileRenderTesselateResult {
 	HALT
 }
 
-private fun vertexTextureBuilder() = DynamicVertexBuilder(GLFlatAttributeList.VERTEX_TEXTURE, VertexType.QUADS)
+private fun vertexTextureBuilder() = DynamicVertexBuilder(GLFlatAttributeList.VERTEX_HSV_TEXTURE, VertexType.QUADS)
 
 private class TileEqualityTester(val definition: TileDefinition) : EqualityRuleTester {
-	override fun test(tile: ChunkTile?): Boolean {
+	override fun test(tile: TileState?): Boolean {
 		return tile?.def == definition
 	}
 }
 
 private class ModifierEqualityTester(val definition: MaterialModifier) : EqualityRuleTester {
-	override fun test(tile: ChunkTile?): Boolean {
+	override fun test(tile: TileState?): Boolean {
 		return tile?.modifier == definition
 	}
 }
@@ -191,7 +194,7 @@ class TileRenderer(val state: GLStateTracker, val def: IRenderableTile) {
 	val bakedBackgroundProgramState = state.tileRenderers.background(texture)
 	// private var notifiedDepth = false
 
-	private fun tesselateAt(piece: RenderPiece, getter: ITileChunk, builder: DynamicVertexBuilder, pos: Vector2i, offset: Vector2i = Vector2i.ZERO) {
+	private fun tesselateAt(self: TileState, piece: RenderPiece, getter: ITileChunk, builder: DynamicVertexBuilder, pos: Vector2i, offset: Vector2i = Vector2i.ZERO, isModifier: Boolean) {
 		val fx = pos.x.toFloat()
 		val fy = pos.y.toFloat()
 
@@ -212,38 +215,35 @@ class TileRenderer(val state: GLStateTracker, val def: IRenderableTile) {
 			d += offset.y / PIXELS_IN_STARBOUND_UNITf
 		}
 
-		if (def.renderParameters.variants == 0 || piece.texture != null || piece.variantStride == null) {
-			val (u0, v0) = texture.pixelToUV(piece.texturePosition)
-			val (u1, v1) = texture.pixelToUV(piece.texturePosition + piece.textureSize)
+		var mins = piece.texturePosition
+		var maxs = piece.texturePosition + piece.textureSize
 
-			builder.quadZ(
-				a,
-				b,
-				c,
-				d,
-				Z_LEVEL, VertexTransformers.uv(u0, v1, u1, v0))
-		} else {
+		if (def.renderParameters.variants != 0 && piece.variantStride != null && piece.texture == null) {
 			val variant = (getter.randomDoubleFor(pos) * def.renderParameters.variants).toInt()
-
-			val (u0, v0) = texture.pixelToUV(piece.texturePosition + piece.variantStride * variant)
-			val (u1, v1) = texture.pixelToUV(piece.texturePosition + piece.textureSize + piece.variantStride * variant)
-
-			builder.quadZ(
-				a,
-				b,
-				c,
-				d,
-				Z_LEVEL, VertexTransformers.uv(u0, v1, u1, v0))
+			mins += piece.variantStride * variant
+			maxs += piece.variantStride * variant
 		}
+
+		if (def.renderParameters.multiColored && piece.colorStride != null && self.color != 0) {
+			mins += piece.colorStride * self.color
+			maxs += piece.colorStride * self.color
+		}
+
+		val (u0, v0) = texture.pixelToUV(mins)
+		val (u1, v1) = texture.pixelToUV(maxs)
+
+		builder.quadZ(a, b, c, d, Z_LEVEL, VertexTransformers.uv(u0, v1, u1, v0).after { it, _ -> it.push(if (!isModifier || self.modifier?.grass == true) self.hueShift else 0f) })
 	}
 
 	private fun tesselatePiece(
+		self: TileState,
 		matchPiece: RenderMatch,
 		getter: ITileChunk,
 		layers: TileLayerList,
 		pos: Vector2i,
 		thisBuilder: DynamicVertexBuilder,
-		background: Boolean
+		background: Boolean,
+		isModifier: Boolean,
 	): TileRenderTesselateResult {
 		if (matchPiece.test(getter, equalityTester, pos)) {
 			for (renderPiece in matchPiece.pieces) {
@@ -254,14 +254,14 @@ class TileRenderer(val state: GLStateTracker, val def: IRenderableTile) {
 						state.tileRenderers.foreground(state.loadNamedTexture(renderPiece.piece.texture!!))
 					}
 
-					tesselateAt(renderPiece.piece, getter, layers.computeIfAbsent(program, def.renderParameters.zLevel, ::vertexTextureBuilder), pos, renderPiece.offset)
+					tesselateAt(self, renderPiece.piece, getter, layers.computeIfAbsent(program, def.renderParameters.zLevel, ::vertexTextureBuilder), pos, renderPiece.offset, isModifier)
 				} else {
-					tesselateAt(renderPiece.piece, getter, thisBuilder, pos, renderPiece.offset)
+					tesselateAt(self, renderPiece.piece, getter, thisBuilder, pos, renderPiece.offset, isModifier)
 				}
 			}
 
 			for (subPiece in matchPiece.subMatches) {
-				val matched = tesselatePiece(subPiece, getter, layers, pos, thisBuilder, background)
+				val matched = tesselatePiece(self, subPiece, getter, layers, pos, thisBuilder, background, isModifier)
 
 				if (matched == TileRenderTesselateResult.HALT || matched == TileRenderTesselateResult.CONTINUE && matchPiece.haltOnSubMatch) {
 					return TileRenderTesselateResult.HALT
@@ -287,7 +287,7 @@ class TileRenderer(val state: GLStateTracker, val def: IRenderableTile) {
 	 *
 	 * Тесселирует тайлы в нужный VertexBuilder с масштабом согласно константе [PIXELS_IN_STARBOUND_UNITf]
 	 */
-	fun tesselate(getter: ITileChunk, layers: TileLayerList, pos: Vector2i, background: Boolean = false) {
+	fun tesselate(self: TileState, getter: ITileChunk, layers: TileLayerList, pos: Vector2i, background: Boolean = false, isModifier: Boolean = false) {
 		// если у нас нет renderTemplate
 		// то мы просто не можем его отрисовать
 		val template = def.renderTemplate
@@ -296,7 +296,7 @@ class TileRenderer(val state: GLStateTracker, val def: IRenderableTile) {
 
 		for ((_, matcher) in template.matches) {
 			for (matchPiece in matcher) {
-				val matched = tesselatePiece(matchPiece, getter, layers, pos, vertexBuilder, background)
+				val matched = tesselatePiece(self, matchPiece, getter, layers, pos, vertexBuilder, background, isModifier)
 
 				if (matched == TileRenderTesselateResult.HALT) {
 					break
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/liquid/LiquidDefinition.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/liquid/LiquidDefinition.kt
new file mode 100644
index 00000000..ccf0c0ea
--- /dev/null
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/liquid/LiquidDefinition.kt
@@ -0,0 +1,53 @@
+package ru.dbotthepony.kstarbound.defs.liquid
+
+import com.google.gson.GsonBuilder
+import ru.dbotthepony.kstarbound.io.KConcreteTypeAdapter
+import ru.dbotthepony.kstarbound.registerTypeAdapter
+import ru.dbotthepony.kvector.vector.Color
+
+data class LiquidDefinition(
+	val name: String,
+	val liquidId: Int,
+	val description: String = "...",
+	val tickDelta: Int = 1,
+	val color: Color,
+	val itemDrop: String? = null,
+	val statusEffects: List<String> = listOf(),
+	val interactions: List<Interaction> = listOf(),
+	val texture: String,
+	val bottomLightMix: Color,
+	val textureMovementFactor: Double,
+) {
+	data class Interaction(val liquid: Int, val liquidResult: Int? = null, val materialResult: String? = null) {
+		init {
+			require(liquidResult != null || materialResult != null) { "Both liquidResult and materialResult are missing" }
+		}
+	}
+
+	companion object {
+		val ADAPTER = KConcreteTypeAdapter.Builder(LiquidDefinition::class)
+			.plain(LiquidDefinition::name)
+			.plain(LiquidDefinition::liquidId)
+			.plain(LiquidDefinition::description)
+			.plain(LiquidDefinition::tickDelta)
+			.plain(LiquidDefinition::color)
+			.plain(LiquidDefinition::itemDrop)
+			.list(LiquidDefinition::statusEffects)
+			.list(LiquidDefinition::interactions)
+			.plain(LiquidDefinition::texture)
+			.plain(LiquidDefinition::bottomLightMix)
+			.plain(LiquidDefinition::textureMovementFactor)
+			.build()
+
+		val INTERACTION_ADAPTER = KConcreteTypeAdapter.Builder(Interaction::class)
+			.plain(Interaction::liquid)
+			.plain(Interaction::liquidResult)
+			.plain(Interaction::materialResult)
+			.build()
+
+		fun registerGson(gsonBuilder: GsonBuilder) {
+			gsonBuilder.registerTypeAdapter(ADAPTER)
+			gsonBuilder.registerTypeAdapter(INTERACTION_ADAPTER)
+		}
+	}
+}
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/tile/MaterialModifier.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/tile/MaterialModifier.kt
index 7eb0847f..49dc0e0d 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/tile/MaterialModifier.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/tile/MaterialModifier.kt
@@ -11,6 +11,7 @@ data class MaterialModifier(
 	val health: Int = 0,
 	val harvestLevel: Int = 0,
 	val breaksWithTile: Boolean = true,
+	val grass: Boolean = false,
 	val miningSounds: List<String> = listOf(),
 	val miningParticle: String? = null,
 	override val renderTemplate: RenderTemplate,
@@ -29,6 +30,7 @@ data class MaterialModifier(
 			.plain(MaterialModifier::health)
 			.plain(MaterialModifier::harvestLevel)
 			.plain(MaterialModifier::breaksWithTile)
+			.plain(MaterialModifier::grass)
 			.list(MaterialModifier::miningSounds)
 			.plain(MaterialModifier::miningParticle)
 			.plain(MaterialModifier::renderTemplate, RenderTemplate.CACHE)
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/tile/RenderParameters.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/tile/RenderParameters.kt
index 48b80930..23eab3a0 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/tile/RenderParameters.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/tile/RenderParameters.kt
@@ -4,6 +4,8 @@ import com.google.gson.GsonBuilder
 import ru.dbotthepony.kstarbound.Starbound
 import ru.dbotthepony.kstarbound.io.KConcreteTypeAdapter
 
+const val TILE_COLOR_VARIANTS = 9
+
 data class RenderParameters(
 	val texture: String,
 	val variants: Int = 0,
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/tile/RenderTemplate.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/tile/RenderTemplate.kt
index ef47ea20..57b272e6 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/tile/RenderTemplate.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/tile/RenderTemplate.kt
@@ -12,7 +12,7 @@ import ru.dbotthepony.kstarbound.Starbound
 import ru.dbotthepony.kstarbound.io.EnumAdapter
 import ru.dbotthepony.kstarbound.io.KConcreteTypeAdapter
 import ru.dbotthepony.kstarbound.util.WriteOnce
-import ru.dbotthepony.kstarbound.world.ChunkTile
+import ru.dbotthepony.kstarbound.world.TileState
 import ru.dbotthepony.kstarbound.world.ITileGetter
 import ru.dbotthepony.kvector.vector.nint.Vector2i
 import java.util.concurrent.ConcurrentHashMap
@@ -38,7 +38,7 @@ data class RenderPiece(
 }
 
 fun interface EqualityRuleTester {
-	fun test(tile: ChunkTile?): Boolean
+	fun test(tile: TileState?): Boolean
 }
 
 data class RenderRuleList(
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/io/KConcreteTypeAdapter.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/io/KConcreteTypeAdapter.kt
index 41a0be89..815df8d0 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/io/KConcreteTypeAdapter.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/io/KConcreteTypeAdapter.kt
@@ -279,7 +279,7 @@ class KConcreteTypeAdapter<T : Any>(
 					val name = fieldId.toString()
 
 					if (loggedMisses.add(name)) {
-						LOGGER.warn("Skipping JSON field with name $name because ${bound.qualifiedName} has no such field")
+						LOGGER.warn("Skipping JSON field with name $name because ${bound.simpleName} has no such field")
 					}
 
 					reader.skipValue()
@@ -306,7 +306,7 @@ class KConcreteTypeAdapter<T : Any>(
 
 				if (fieldId == -1) {
 					if (loggedMisses.add(name)) {
-						LOGGER.warn("Skipping JSON field with name $name because ${bound.qualifiedName} has no such field")
+						LOGGER.warn("Skipping JSON field with name $name because ${bound.simpleName} has no such field")
 					}
 
 					reader.skipValue()
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/util/TwoDimensionalArray.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/util/TwoDimensionalArray.kt
new file mode 100644
index 00000000..4e0dca75
--- /dev/null
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/util/TwoDimensionalArray.kt
@@ -0,0 +1,35 @@
+package ru.dbotthepony.kstarbound.util
+
+import kotlin.reflect.KClass
+
+class TwoDimensionalArray<T : Any>(clazz: KClass<T>, private val width: Int, private val height: Int) {
+	private val memory: Array<T?> = java.lang.reflect.Array.newInstance(clazz.java, width * height) as Array<T?>
+
+	operator fun get(x: Int, y: Int): T? {
+		if (x !in 0 until width) {
+			throw IndexOutOfBoundsException("X $x is out of bounds between 0 and $width")
+		}
+
+		if (y !in 0 until height) {
+			throw IndexOutOfBoundsException("Y $y is out of bounds between 0 and $height")
+		}
+
+		return memory[x + y * width]
+	}
+
+	operator fun set(x: Int, y: Int, value: T): T? {
+		if (x !in 0 until width) {
+			throw IndexOutOfBoundsException("X $x is out of bounds between 0 and $width")
+		}
+
+		if (y !in 0 until height) {
+			throw IndexOutOfBoundsException("Y $y is out of bounds between 0 and $height")
+		}
+
+		val old = memory[x + y * width]
+		memory[x + y * width] = value
+		return old
+	}
+}
+
+inline fun <reified T : Any> TwoDimensionalArray(width: Int, height: Int) = TwoDimensionalArray(T::class, width, height)
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/Chunk.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/Chunk.kt
index 98988ff4..ea8dfbbf 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/Chunk.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/Chunk.kt
@@ -4,8 +4,11 @@ import ru.dbotthepony.kbox2d.api.BodyDef
 import ru.dbotthepony.kbox2d.api.FixtureDef
 import ru.dbotthepony.kbox2d.collision.shapes.PolygonShape
 import ru.dbotthepony.kbox2d.dynamics.B2Fixture
+import ru.dbotthepony.kstarbound.defs.liquid.LiquidDefinition
 import ru.dbotthepony.kstarbound.defs.tile.MaterialModifier
+import ru.dbotthepony.kstarbound.defs.tile.TILE_COLOR_VARIANTS
 import ru.dbotthepony.kstarbound.defs.tile.TileDefinition
+import ru.dbotthepony.kstarbound.util.TwoDimensionalArray
 import ru.dbotthepony.kstarbound.world.entities.Entity
 import ru.dbotthepony.kstarbound.world.phys.RectTileFlooderDepthFirst
 import ru.dbotthepony.kstarbound.world.phys.RectTileFlooderSizeFirst
@@ -18,19 +21,46 @@ import kotlin.collections.HashSet
 /**
  * Представляет из себя класс, который содержит состояние тайла на заданной позиции
  */
-class ChunkTile(val chunk: Chunk<*, *>.TileLayer, val def: TileDefinition) {
+class TileState(val chunk: Chunk<*, *>.TileLayer, val def: TileDefinition) {
 	var color = 0
 		set(value) {
 			if (value != field) {
+				if (!def.renderParameters.multiColored) {
+					throw IllegalStateException("${def.materialName} can't be colored")
+				}
+
+				if (value !in 0 until TILE_COLOR_VARIANTS) {
+					throw IndexOutOfBoundsException("Tile variant $value is out of possible range 0 to $TILE_COLOR_VARIANTS")
+				}
+
 				field = value
 				chunk.incChangeset()
 			}
 		}
 
-	var variant = -1
+	/**
+	 * Выставляет hue shift как байтовое значение в диапазоне 0 .. 255
+	 */
+	fun setHueShift(value: Int) {
+		if (value < 0) {
+			hueShift = 0f
+		} else if (value > 255) {
+			hueShift = 360f
+		} else {
+			hueShift = (value / 255f) * 360f
+		}
+	}
+
+	var hueShift = 0f
 		set(value) {
-			if (value != field) {
-				field = value
+			var newValue = value % 360f
+
+			if (newValue < 0f) {
+				newValue += 360f
+			}
+
+			if (newValue != field) {
+				field = newValue
 				chunk.incChangeset()
 			}
 		}
@@ -44,23 +74,28 @@ class ChunkTile(val chunk: Chunk<*, *>.TileLayer, val def: TileDefinition) {
 		}
 
 	override fun equals(other: Any?): Boolean {
-		return other is ChunkTile && other.color == color && other.variant == variant && other.modifier === modifier && other.def === def
+		return other is TileState && other.color == color && other.modifier === modifier && other.def === def
 	}
 
 	override fun toString(): String {
-		return "ChunkTile[$chunk, material = ${def.materialName}, color = $color, variant = $variant, modifier = ${modifier?.modName}]"
+		return "ChunkTile[$chunk, material = ${def.materialName}, color = $color, modifier = ${modifier?.modName}]"
 	}
 
 	override fun hashCode(): Int {
 		var result = chunk.hashCode()
 		result = 31 * result + def.hashCode()
 		result = 31 * result + color
-		result = 31 * result + variant
 		result = 31 * result + (modifier?.hashCode() ?: 0)
 		return result
 	}
 }
 
+data class LiquidState(val chunk: Chunk<*, *>.TileLayer, val def: LiquidDefinition) {
+	var pressure: Float = 0f
+	var level: Float = 1f
+	var isInfinite: Boolean = false
+}
+
 private fun ccwSortScore(point: Vector2d, axis: Vector2d): Double {
 	if (point.x > 0.0) {
 		return point.dot(axis)
@@ -308,16 +343,16 @@ abstract class Chunk<WorldType : World<WorldType, This>, This : Chunk<WorldType,
 		/**
 		 * Хранит тайлы как x + y * CHUNK_SIZE
 		 */
-		private val tiles = arrayOfNulls<ChunkTile>(CHUNK_SIZE * CHUNK_SIZE)
+		private val tiles = arrayOfNulls<TileState>(CHUNK_SIZE * CHUNK_SIZE)
 
-		override operator fun get(x: Int, y: Int): ChunkTile? {
+		override operator fun get(x: Int, y: Int): TileState? {
 			if (isOutside(x, y))
 				return null
 
 			return tiles[x or (y shl CHUNK_SHIFT)]
 		}
 
-		operator fun set(x: Int, y: Int, tile: ChunkTile?) {
+		operator fun set(x: Int, y: Int, tile: TileState?) {
 			if (isOutside(x, y))
 				throw IndexOutOfBoundsException("Trying to set tile ${tile?.def?.materialName} at $x $y, but that is outside of chunk's range")
 
@@ -326,11 +361,11 @@ abstract class Chunk<WorldType : World<WorldType, This>, This : Chunk<WorldType,
 			markPhysicsDirty()
 		}
 
-		override operator fun set(x: Int, y: Int, tile: TileDefinition?): ChunkTile? {
+		override operator fun set(x: Int, y: Int, tile: TileDefinition?): TileState? {
 			if (isOutside(x, y))
 				throw IndexOutOfBoundsException("Trying to set tile ${tile?.materialName} at $x $y, but that is outside of chunk's range")
 
-			val chunkTile = if (tile != null) ChunkTile(this, tile) else null
+			val chunkTile = if (tile != null) TileState(this, tile) else null
 			this[x, y] = chunkTile
 			changeset++
 			markPhysicsDirty()
@@ -344,8 +379,11 @@ abstract class Chunk<WorldType : World<WorldType, This>, This : Chunk<WorldType,
 
 	val foreground = TileLayer()
 	val background = TileLayer()
+
+	protected val liquidStates: TwoDimensionalArray<LiquidState> = TwoDimensionalArray(CHUNK_SIZE, CHUNK_SIZE)
+
 	protected val entities = HashSet<Entity>()
-	val entitiesAccess = Collections.unmodifiableSet(entities)
+	val entitiesAccess: Set<Entity> = Collections.unmodifiableSet(entities)
 
 	protected abstract fun onEntityAdded(entity: Entity)
 	protected abstract fun onEntityTransferedToThis(entity: Entity, otherChunk: This)
@@ -396,8 +434,8 @@ abstract class Chunk<WorldType : World<WorldType, This>, This : Chunk<WorldType,
 		val EMPTY = object : IMutableTileChunk {
 			override val pos = ChunkPos(0, 0)
 
-			override fun get(x: Int, y: Int): ChunkTile? = null
-			override fun set(x: Int, y: Int, tile: TileDefinition?): ChunkTile? = null
+			override fun get(x: Int, y: Int): TileState? = null
+			override fun set(x: Int, y: Int, tile: TileDefinition?): TileState? = null
 		}
 
 		private val aabbBase = AABB(
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/ChunkAPI.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/ChunkAPI.kt
index c951244c..96c14b8b 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/ChunkAPI.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/ChunkAPI.kt
@@ -26,7 +26,7 @@ interface ITileGetter : ITileMap {
 	/**
 	 * Возвращает тайл по ОТНОСИТЕЛЬНЫМ координатам внутри чанка
 	 */
-	operator fun get(x: Int, y: Int): ChunkTile?
+	operator fun get(x: Int, y: Int): TileState?
 
 	/**
 	 * Возвращает тайл по ОТНОСИТЕЛЬНЫМ координатам внутри чанка
@@ -38,8 +38,8 @@ interface ITileGetter : ITileMap {
 	 *
 	 * Вектор имеет ОТНОСИТЕЛЬНЫЕ значения внутри самого чанка
 	 */
-	val posToTile: Iterator<Pair<Vector2i, ChunkTile?>> get() {
-		return object : Iterator<Pair<Vector2i, ChunkTile?>> {
+	val posToTile: Iterator<Pair<Vector2i, TileState?>> get() {
+		return object : Iterator<Pair<Vector2i, TileState?>> {
 			private var x = 0
 			private var y = 0
 
@@ -49,7 +49,7 @@ interface ITileGetter : ITileMap {
 				return idx() < CHUNK_SIZE * CHUNK_SIZE
 			}
 
-			override fun next(): Pair<Vector2i, ChunkTile?> {
+			override fun next(): Pair<Vector2i, TileState?> {
 				if (!hasNext()) {
 					throw IllegalStateException("Already iterated everything!")
 				}
@@ -121,7 +121,7 @@ interface ITileSetter : ITileMap {
 	/**
 	 * Устанавливает тайл по ОТНОСИТЕЛЬНЫМ координатам внутри чанка
 	 */
-	operator fun set(x: Int, y: Int, tile: TileDefinition?): ChunkTile?
+	operator fun set(x: Int, y: Int, tile: TileDefinition?): TileState?
 	/**
 	 * Устанавливает тайл по ОТНОСИТЕЛЬНЫМ координатам внутри чанка
 	 */
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/TileView.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/TileView.kt
index b83db973..681b891f 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/TileView.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/TileView.kt
@@ -22,7 +22,7 @@ open class TileView(
 	open val bottomLeft: ITileChunk?,
 	open val bottomRight: ITileChunk?,
 ) : ITileChunk {
-	override fun get(x: Int, y: Int): ChunkTile? {
+	override fun get(x: Int, y: Int): TileState? {
 		if (x in 0 ..CHUNK_SIZE_FF) {
 			if (y in 0 ..CHUNK_SIZE_FF) {
 				return center[x, y]
@@ -75,7 +75,7 @@ class MutableTileView(
 	override val bottomLeft: IMutableTileChunk?,
 	override val bottomRight: IMutableTileChunk?,
 ) : TileView(center, right, top, topRight, topLeft, left, bottom, bottomLeft, bottomRight), IMutableTileChunk {
-	override fun set(x: Int, y: Int, tile: TileDefinition?): ChunkTile? {
+	override fun set(x: Int, y: Int, tile: TileDefinition?): TileState? {
 		if (x in 0 .. CHUNK_SIZE_FF) {
 			if (y in 0 .. CHUNK_SIZE_FF) {
 				return center.set(x, y, tile)
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/World.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/World.kt
index e96ebe2e..5a0a118a 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/World.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/World.kt
@@ -323,7 +323,7 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
 		)
 	}
 
-	fun getTile(pos: Vector2i): ChunkTile? {
+	fun getTile(pos: Vector2i): TileState? {
 		return get(ChunkPos.fromTilePosition(pos))?.foreground?.get(ChunkPos.normalizeCoordinate(pos.x), ChunkPos.normalizeCoordinate(pos.y))
 	}
 
@@ -333,7 +333,7 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
 		return chunk
 	}
 
-	fun getBackgroundTile(pos: Vector2i): ChunkTile? {
+	fun getBackgroundTile(pos: Vector2i): TileState? {
 		return get(ChunkPos.fromTilePosition(pos))?.background?.get(ChunkPos.normalizeCoordinate(pos.x), ChunkPos.normalizeCoordinate(pos.y))
 	}
 
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/phys/RectTileFlooderDepthFirst.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/phys/RectTileFlooderDepthFirst.kt
index 3fb686ff..e33a29cc 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/phys/RectTileFlooderDepthFirst.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/phys/RectTileFlooderDepthFirst.kt
@@ -2,11 +2,11 @@ package ru.dbotthepony.kstarbound.world.phys
 
 import ru.dbotthepony.kstarbound.world.CHUNK_SHIFT
 import ru.dbotthepony.kstarbound.world.CHUNK_SIZE_FF
-import ru.dbotthepony.kstarbound.world.ChunkTile
+import ru.dbotthepony.kstarbound.world.TileState
 import ru.dbotthepony.kvector.vector.nint.Vector2i
 
 class RectTileFlooderDepthFirst(
-	private val tiles: Array<ChunkTile?>,
+	private val tiles: Array<TileState?>,
 	private val seen: BooleanArray,
 	rootx: Int,
 	rooty: Int
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/phys/RectTileFlooderSizeFirst.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/phys/RectTileFlooderSizeFirst.kt
index e32c97a4..c6f7a6f1 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/phys/RectTileFlooderSizeFirst.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/phys/RectTileFlooderSizeFirst.kt
@@ -2,11 +2,11 @@ package ru.dbotthepony.kstarbound.world.phys
 
 import ru.dbotthepony.kstarbound.world.CHUNK_SHIFT
 import ru.dbotthepony.kstarbound.world.CHUNK_SIZE_FF
-import ru.dbotthepony.kstarbound.world.ChunkTile
+import ru.dbotthepony.kstarbound.world.TileState
 import ru.dbotthepony.kvector.vector.nint.Vector2i
 
 class RectTileFlooderSizeFirst(
-	private val tiles: Array<ChunkTile?>,
+	private val tiles: Array<TileState?>,
 	private val seen: BooleanArray,
 	private val rootx: Int,
 	private val rooty: Int
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/phys/TileFlooder.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/phys/TileFlooder.kt
index 68eb397b..415db2b0 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/phys/TileFlooder.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/phys/TileFlooder.kt
@@ -2,7 +2,7 @@ package ru.dbotthepony.kstarbound.world.phys
 
 import ru.dbotthepony.kstarbound.world.CHUNK_SHIFT
 import ru.dbotthepony.kstarbound.world.CHUNK_SIZE_FF
-import ru.dbotthepony.kstarbound.world.ChunkTile
+import ru.dbotthepony.kstarbound.world.TileState
 import ru.dbotthepony.kvector.vector.ndouble.Vector2d
 
 private data class TileExposure(
@@ -14,7 +14,7 @@ private data class TileExposure(
 )
 
 private class TileFlooder(
-	private val tiles: Array<ChunkTile?>,
+	private val tiles: Array<TileState?>,
 	private val seen: BooleanArray,
 	rootx: Int,
 	rooty: Int
diff --git a/src/main/resources/shaders/fragment/texture_color_per_vertex.glsl b/src/main/resources/shaders/fragment/texture_color_per_vertex.glsl
new file mode 100644
index 00000000..ad64b66d
--- /dev/null
+++ b/src/main/resources/shaders/fragment/texture_color_per_vertex.glsl
@@ -0,0 +1,38 @@
+
+#version 460
+
+uniform sampler2D _texture;
+uniform vec4 _color;
+
+in vec2 _uv_out;
+in float _hsv_vertex;
+out vec4 _color_out;
+
+// https://gist.github.com/983/e170a24ae8eba2cd174f
+vec3 rgb2hsv(vec3 c)
+{
+	vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
+	vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
+	vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
+
+	float d = q.x - min(q.w, q.y);
+	float e = 1.0e-10;
+	return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
+}
+
+vec3 hsv2rgb(vec3 c)
+{
+	vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
+	vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
+	return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
+}
+
+void main() {
+	vec4 texel = texture(_texture, _uv_out);
+
+	vec3 hsv2 = rgb2hsv(vec3(texel[0], texel[1], texel[2]));
+	hsv2[0] += _hsv_vertex / 360;
+	vec4 rgb2 = vec4(hsv2rgb(hsv2), texel[3]);
+
+	_color_out = _color * rgb2;
+}
diff --git a/src/main/resources/shaders/vertex/texture_hsv.glsl b/src/main/resources/shaders/vertex/texture_hsv.glsl
new file mode 100644
index 00000000..86728b0b
--- /dev/null
+++ b/src/main/resources/shaders/vertex/texture_hsv.glsl
@@ -0,0 +1,16 @@
+
+#version 460
+
+layout (location = 0) in vec3 _pos;
+layout (location = 1) in vec2 _uv_in;
+layout (location = 2) in float _hsv_in;
+
+out vec2 _uv_out;
+out float _hsv_vertex;
+uniform mat4 _transform;
+
+void main() {
+	_uv_out = _uv_in;
+	_hsv_vertex = _hsv_in;
+	gl_Position = _transform * vec4(_pos, 1.0);
+}