Partes 1-3: malla, colores y alturas de celdaPartes 4-7: baches, ríos y caminosPartes 8-11: agua, accidentes geográficos y murallasPartes 12-15: guardar y cargar, texturas, distanciasPartes 16-19: encontrar el camino, escuadrones de jugadores, animacionesPartes 20-23: niebla de guerra, investigación de mapas, generación de procedimientosPartes 24-27: ciclo del agua, erosión, biomas, mapa cilíndricoParte 4: Rugosidades
Tabla de contenidos
- Muestra la textura del ruido.
- Mueve los vértices.
- Preservamos la planeidad de las células.
- Subdividir los bordes de las celdas.
Si bien nuestra cuadrícula era un patrón estricto de panales. En esta parte, agregaremos protuberancias para que el mapa se vea más natural.
No más ni siquiera hexágonos.El ruido
Para agregar protuberancias, necesitamos aleatorización, pero no aleatoriedad verdadera. Queremos que todo sea consistente al cambiar el mapa. De lo contrario, cuando realice algún cambio, los objetos saltarán. Es decir, necesitamos alguna forma de ruido pseudoaleatorio reproducible.
Un buen candidato es el ruido de Perlin. Es reproducible en cualquier lugar. Cuando se combinan varias frecuencias, también crea ruido, que puede variar mucho a grandes distancias, pero permanece casi igual a distancias pequeñas. Gracias a esto, se pueden crear distorsiones relativamente suaves. Los puntos adyacentes entre sí generalmente permanecen cerca y no están dispersos en direcciones opuestas.
Podemos generar ruido de Perlin mediante programación. En el tutorial de
Ruido , explico cómo hacer esto. Pero también podemos tomar muestras de una textura de ruido pregenerada. La ventaja de usar textura es que es más simple y mucho más rápido que calcular el ruido de múltiples frecuencias de Perlin. Su desventaja es que la textura ocupa más memoria y cubre solo un área pequeña de ruido. Por lo tanto, debe estar perfectamente conectado y ser lo suficientemente grande como para que la repetición no sea sorprendente.
Textura de ruido
Usaremos la textura, por lo que el tutorial de
Ruido es opcional. Entonces necesitamos una textura. Aquí esta:
Conecte perfectamente la textura de ruido perlin.La textura que se muestra arriba contiene el ruido de frecuencias múltiples perfectamente acoplado de Perlin. Esta es una imagen en escala de grises. Su valor promedio es 0.5, y los valores extremos tienden a 0 y 1.
Pero espera, solo hay un valor para cada punto. Si necesitamos distorsión 3D, ¡entonces necesitamos al menos tres muestras pseudoaleatorias! Por lo tanto, necesitamos dos texturas más con ruido diferente.
Podemos crearlos o almacenar diferentes valores de ruido en cada uno de los canales de color. Esto nos permitirá almacenar hasta cuatro patrones de ruido en una textura. Aquí está esta textura.
Cuatro en uno¿Cómo crear tal textura?Usé NumberFlow . Este es el editor de texturas de procedimiento que creé para Unity.
Descarga esta textura e impórtala en tu proyecto de Unity. Como vamos a muestrear la textura a través del código, debería ser legible. Cambie
Tipo de textura a
Avanzado y habilite
Lectura / Escritura habilitada . Esto guardará los datos de textura en la memoria y se puede acceder desde el código C #. Establezca
Formato en
Truecolor automático ; de lo contrario, nada funcionará. No queremos que la compresión de textura destruya nuestro patrón de ruido.
Puede deshabilitar
Generar mapas Mip , porque no los necesitamos. También habilite
Bypass sRGB Sampling . No necesitaremos esto, pero será así. Este parámetro indica que la textura no contiene datos de color en el espacio gamma.
Textura de ruido importada.
¿Cuándo es importante el muestreo sRGB?Si quisiéramos usar una textura en un sombreador, eso marcaría la diferencia. Cuando se utiliza el modo de representación lineal, el muestreo de textura convierte automáticamente los datos de color de gamma a espacio de color lineal. En el caso de nuestra textura de ruido, esto conducirá a resultados incorrectos, por lo que no necesitamos esto.
¿Por qué mi configuración de importación de texturas se ve diferente?Se cambiaron después de escribir este tutorial. Debe usar la configuración predeterminada de textura 2D, sRGB (Textura de color) debe estar deshabilitado y la Compresión debe estar configurada en Ninguno .
Muestreo de ruido
HexMetrics
funcionalidad de muestreo de ruido a
HexMetrics
para que pueda usarlo en cualquier lugar. Esto significa que
HexMetrics
debe contener una referencia a la textura de ruido.
public static Texture2D noiseSource;
Como este no es un componente, no podemos asignarle una textura a través del editor. Por lo tanto, como intermediario, utilizamos
HexGrid
. Dado que
HexGrid
actuará primero, estará bien si pasamos la textura al comienzo de su método
Awake
.
public Texture2D noiseSource; void Awake () { HexMetrics.noiseSource = noiseSource; … }
Sin embargo, este enfoque no sobrevivirá a la recompilación en el modo Play. El motor de Unity no serializa las variables estáticas. Para resolver este problema, reasigne la textura en el método de evento
OnEnable
. Este método se llamará después de la recompilación.
void OnEnable () { HexMetrics.noiseSource = noiseSource; }
Asigna una textura de ruido.Ahora que
HexMetrics
tiene acceso a la textura, agreguemos un método de muestreo de ruido conveniente. Este método toma una posición en el mundo y crea un vector 4D que contiene cuatro muestras de ruido.
public static Vector4 SampleNoise (Vector3 position) { }
Las muestras se crearon muestreando la textura usando un filtro bilineal, en el que las coordenadas del mundo X y Z se usaron como coordenadas UV. Dado que nuestra fuente de ruido es bidimensional, ignoramos la tercera coordenada del mundo. Si la fuente de ruido fuera tridimensional, también usaríamos la coordenada Y.
Como resultado, obtenemos un color que se puede convertir en un vector 4D. Tal reducción puede ser indirecta, es decir, podemos devolver el color directamente, sin incluir explícitamente
(Vector4)
.
public static Vector4 SampleNoise (Vector3 position) { return noiseSource.GetPixelBilinear(position.x, position.z); }
¿Cómo funciona el filtrado bilineal?Para obtener una explicación de las coordenadas UV y el filtrado de texturas, consulte el tutorial de
Rendering 2, Fundamentos de Shader paquete de la unidadMovimiento de vértices
Vamos a distorsionar nuestra grilla suave de panales, moviendo individualmente cada uno de los vértices. Para hacer esto, agreguemos el método
Perturb
a
Perturb
. Toma un punto inmóvil y devuelve el movido. Para hacer esto, usa un punto no desplazado al muestrear ruido.
Vector3 Perturb (Vector3 position) { Vector4 sample = HexMetrics.SampleNoise(position); }
Simplemente agreguemos las muestras de ruido X, Y y Z directamente a las coordenadas de puntos correspondientes y usémoslas como resultado.
Vector3 Perturb (Vector3 position) { Vector4 sample = HexMetrics.SampleNoise(position); position.x += sample.x; position.y += sample.y; position.z += sample.z; return position; }
¿Cómo cambiamos rápidamente
HexMesh
para mover todos los vértices?
AddTriangle
cambiar cada vértice al agregar vértices a la lista en los
AddQuad
AddTriangle
y
AddQuad
. Hagámoslo
void AddTriangle (Vector3 v1, Vector3 v2, Vector3 v3) { int vertexIndex = vertices.Count; vertices.Add(Perturb(v1)); vertices.Add(Perturb(v2)); vertices.Add(Perturb(v3)); … } void AddQuad (Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4) { int vertexIndex = vertices.Count; vertices.Add(Perturb(v1)); vertices.Add(Perturb(v2)); vertices.Add(Perturb(v3)); vertices.Add(Perturb(v4)); … }
¿Los cuadrángulos permanecerán planos después de mover sus vértices?Lo más probable es que no. Consisten en dos triángulos que ya no estarán en el mismo plano. Sin embargo, dado que estos triángulos tienen dos vértices comunes, las normales de estos vértices se suavizarán. Esto significa que no tendremos transiciones bruscas entre dos triángulos. Si la distorsión no es demasiado grande, aún percibiremos los cuadrángulos como planos.
Los vértices se mueven o no.Si bien los cambios no son muy notables, solo las etiquetas de las celdas han desaparecido. Esto sucedió porque agregamos muestras de ruido a los puntos, y siempre son positivos. Por lo tanto, como resultado, todos los triángulos se elevaron por encima de sus marcas, cerrándolos. Debemos centrar los cambios para que ocurran en ambas direcciones. Cambie el intervalo de la muestra de ruido de 0–1 a −1–1.
Vector3 Perturb (Vector3 position) { Vector4 sample = HexMetrics.SampleNoise(position); position.x += sample.x * 2f - 1f; position.y += sample.y * 2f - 1f; position.z += sample.z * 2f - 1f; return position; }
Desplazamiento centrado.La magnitud (fuerza) del desplazamiento.
Ahora es obvio que distorsionamos la cuadrícula, pero el efecto apenas se nota. El cambio en cada dimensión no es más de 1 unidad. Es decir, el desplazamiento máximo teórico es √3 ≈ 1.73 unidades, lo que ocurrirá extremadamente raramente, si es que lo hace. Dado que el radio exterior de las celdas es de 10 unidades, los desplazamientos son relativamente pequeños.
La solución es agregar un parámetro de
HexMetrics
a
HexMetrics
para que pueda escalar los movimientos. Tratemos de usar la fuerza 5. En este caso, el desplazamiento máximo teórico será √75 ≈ 8.66 unidades, lo cual es mucho más notable.
public const float cellPerturbStrength = 5f;
Aplicamos la fuerza multiplicándola por muestras en
HexMesh.Perturb
.
Vector3 Perturb (Vector3 position) { Vector4 sample = HexMetrics.SampleNoise(position); position.x += (sample.x * 2f - 1f) * HexMetrics.cellPerturbStrength; position.y += (sample.y * 2f - 1f) * HexMetrics.cellPerturbStrength; position.z += (sample.z * 2f - 1f) * HexMetrics.cellPerturbStrength; return position; }
Aumento de la fuerza.Escala de ruido
Aunque la cuadrícula se ve bien antes del cambio, todo puede salir mal después de que aparezcan las repisas. Sus picos pueden distorsionarse en direcciones impredeciblemente diferentes, creando caos. Cuando se usa el ruido Perlin, esto no debería suceder.
El problema surge porque usamos directamente las coordenadas del mundo para muestrear el ruido. Debido a esto, la textura se oculta a través de cada unidad, y las celdas son mucho más grandes que este valor. De hecho, la textura se muestrea en puntos arbitrarios, destruyendo su integridad existente.
Filas de 10 por 10 celdas superpuestas de cuadrícula.Tendremos que escalar el muestreo de ruido para que la textura cubra un área mucho más grande.
HexMetrics
esta escala a
HexMetrics
y asígnele un valor de 0.003, y luego
HexMetrics
las coordenadas de las muestras por este factor.
public const float noiseScale = 0.003f; public static Vector4 SampleNoise (Vector3 position) { return noiseSource.GetPixelBilinear( position.x * noiseScale, position.z * noiseScale ); }
De repente resulta que nuestra textura cubre 333 y frac13; unidades cuadradas, y su integridad local se hace evidente.
Ruido escalado.Además, una nueva escala aumenta la distancia entre las juntas de ruido. De hecho, dado que las celdas tienen un diámetro interno de 10√3 unidades, nunca se colocará exactamente en mosaico en la dimensión X. Sin embargo, debido a la integridad local del ruido, en una escala mayor, aún podremos reconocer patrones repetitivos, aproximadamente cada 20 celdas, incluso si los detalles no coinciden. Pero serán obvios solo en el mapa sin otros rasgos característicos.
paquete de la unidadAlinear centros celulares
Mover todos los vértices le da al mapa un aspecto más natural, pero hay varios problemas. Como las celdas ahora son irregulares, sus etiquetas se cruzan con la malla. Y en las uniones de las repisas con acantilados, surgen grietas. Dejaremos las grietas para más tarde, pero ahora nos centraremos en las superficies de las células.
El mapa se volvió menos estricto, pero aparecieron más problemas.La forma más fácil de resolver el problema de la intersección es hacer que los centros de las celdas sean planos. Simplemente no cambiemos la coordenada Y en
HexMesh.Perturb
.
Vector3 Perturb (Vector3 position) { Vector4 sample = HexMetrics.SampleNoise(position); position.x += (sample.x * 2f - 1f) * HexMetrics.cellPerturbStrength;
Celdas alineadas.Con este cambio, todas las posiciones verticales permanecerán sin cambios, tanto en los centros de las celdas como en los escalones de las repisas. Cabe señalar que esto reduce el desplazamiento máximo a √50 ≈ 7.07 solo en el plano XZ.
Este es un buen cambio, porque simplifica la identificación de células individuales y no permite que las repisas se vuelvan demasiado caóticas. Pero aún así sería bueno agregar un pequeño movimiento vertical.
Mover la altura de la celda
En lugar de aplicar movimiento vertical a cada vértice, podemos aplicarlo a una celda. En este caso, cada celda permanecerá plana, pero la variabilidad permanecerá entre las celdas. También sería lógico usar una escala diferente para mover la altura, así que agréguela a
HexMetrics
. Una fuerza de 1.5 unidades crea una ligera variación, aproximadamente igual a la altura de un escalón de la repisa.
public const float elevationPerturbStrength = 1.5f;
Cambie la propiedad
HexCell.Elevation
para que
HexCell.Elevation
este movimiento a la posición vertical de la celda.
public int Elevation { get { return elevation; } set { elevation = value; Vector3 position = transform.localPosition; position.y = value * HexMetrics.elevationStep; position.y += (HexMetrics.SampleNoise(position).y * 2f - 1f) * HexMetrics.elevationPerturbStrength; transform.localPosition = position; Vector3 uiPosition = uiRect.localPosition; uiPosition.z = -position.y; uiRect.localPosition = uiPosition; } }
Para que el movimiento se aplique inmediatamente, necesitamos establecer explícitamente la altura de cada celda en
HexGrid.CreateCell
. De lo contrario, la cuadrícula inicialmente será plana. Hagámoslo al final, después de crear la interfaz de usuario.
void CreateCell (int x, int z, int i) { … cell.Elevation = 0; }
Alturas desplazadas con grietas.Usando las mismas alturas
Muchas grietas aparecieron en la malla, porque cuando triangulamos la malla, no usamos las mismas alturas de celda.
HexCell
una propiedad a
HexCell
para obtener su posición para que pueda usarla en cualquier lugar.
public Vector3 Position { get { return transform.localPosition; } }
Ahora podemos usar esta propiedad en
HexMesh.Triangulate
para determinar el centro de la celda.
void Triangulate (HexDirection direction, HexCell cell) { Vector3 center = cell.Position; … }
Y podemos usarlo en
TriangulateConnection
al definir las posiciones verticales de las celdas vecinas.
void TriangulateConnection ( HexDirection direction, HexCell cell, Vector3 v1, Vector3 v2 ) { … Vector3 bridge = HexMetrics.GetBridge(direction); Vector3 v3 = v1 + bridge; Vector3 v4 = v2 + bridge; v3.y = v4.y = neighbor.Position.y; … HexCell nextNeighbor = cell.GetNeighbor(direction.Next()); if (direction <= HexDirection.E && nextNeighbor != null) { Vector3 v5 = v2 + HexMetrics.GetBridge(direction.Next()); v5.y = nextNeighbor.Position.y; … } }
Uso constante de la altura de la celda.paquete de la unidadUnidad de borde de celda
Aunque las celdas tienen una hermosa variación, todavía se ven como hexágonos obvios. Esto en sí mismo no es un problema, pero podemos mejorar su apariencia.
Células hexagonales claramente visibles.Si tuviéramos más vértices, entonces habría una mayor variabilidad local. Entonces, dividamos cada borde de la celda en dos partes agregando la parte superior del borde en el medio entre cada par de esquinas. Esto significa que
HexMesh.Triangulate
debe agregar no uno, sino dos triángulos.
void Triangulate (HexDirection direction, HexCell cell) { Vector3 center = cell.Position; Vector3 v1 = center + HexMetrics.GetFirstSolidCorner(direction); Vector3 v2 = center + HexMetrics.GetSecondSolidCorner(direction); Vector3 e1 = Vector3.Lerp(v1, v2, 0.5f); AddTriangle(center, v1, e1); AddTriangleColor(cell.color); AddTriangle(center, e1, v2); AddTriangleColor(cell.color); if (direction <= HexDirection.SE) { TriangulateConnection(direction, cell, v1, v2); } }
Doce lados en lugar de seis.Doblar vértices y triángulos agrega más variabilidad a los bordes de la celda. Hagámoslos aún más desiguales triplicando el número de vértices.
Vector3 e1 = Vector3.Lerp(v1, v2, 1f / 3f); Vector3 e2 = Vector3.Lerp(v1, v2, 2f / 3f); AddTriangle(center, v1, e1); AddTriangleColor(cell.color); AddTriangle(center, e1, e2); AddTriangleColor(cell.color); AddTriangle(center, e2, v2); AddTriangleColor(cell.color);
18 ladosDivisión de costillas
Por supuesto, también necesitamos subdividir las juntas de borde. Por lo tanto, pasaremos los nuevos bordes de vértice a
TriangulateConnection
.
if (direction <= HexDirection.SE) { TriangulateConnection(direction, cell, v1, e1, e2, v2); }
Agregue los parámetros apropiados a
TriangulateConnection
para que pueda funcionar con vértices adicionales.
void TriangulateConnection ( HexDirection direction, HexCell cell, Vector3 v1, Vector3 e1, Vector3 e2, Vector3 v2 ) { … }
También necesitamos calcular los bordes adicionales de los bordes para las celdas vecinas. Podemos calcularlos después de conectar el puente al otro lado.
Vector3 bridge = HexMetrics.GetBridge(direction); Vector3 v3 = v1 + bridge; Vector3 v4 = v2 + bridge; v3.y = v4.y = neighbor.Position.y; Vector3 e3 = Vector3.Lerp(v3, v4, 1f / 3f); Vector3 e4 = Vector3.Lerp(v3, v4, 2f / 3f);
Luego necesitamos cambiar la triangulación de la costilla. Hasta que ignoremos las pendientes con las repisas, solo agregue tres en lugar de un quad.
if (cell.GetEdgeType(direction) == HexEdgeType.Slope) { TriangulateEdgeTerraces(v1, v2, cell, v3, v4, neighbor); } else { AddQuad(v1, e1, v3, e3); AddQuadColor(cell.color, neighbor.color); AddQuad(e1, e2, e3, e4); AddQuadColor(cell.color, neighbor.color); AddQuad(e2, v2, e4, v4); AddQuadColor(cell.color, neighbor.color); }
Conexiones subdivididas.La unión de los bordes de los bordes.
Dado que para describir los bordes ahora necesitamos cuatro vértices, sería lógico combinarlos en un conjunto. Esto es más conveniente que trabajar con cuatro vértices independientes. Cree una estructura de
EdgeVertices
simple para esto. Debe contener cuatro vértices que van en sentido horario a lo largo del borde de la celda.
using UnityEngine; public struct EdgeVertices { public Vector3 v1, v2, v3, v4; }
¿No deberían ser serializables?Usaremos esta estructura solo para triangulación. En esta etapa, no necesitamos almacenar los vértices de los bordes, por lo que no es necesario que sean serializables.
Agregue un método de construcción conveniente, que se ocupará del cálculo de los puntos intermedios del borde.
public EdgeVertices (Vector3 corner1, Vector3 corner2) { v1 = corner1; v2 = Vector3.Lerp(corner1, corner2, 1f / 3f); v3 = Vector3.Lerp(corner1, corner2, 2f / 3f); v4 = corner2; }
Ahora podemos agregar un método de triangulación separado a
HexMesh
para crear un abanico de triángulos entre el centro de la celda y uno de sus bordes.
void TriangulateEdgeFan (Vector3 center, EdgeVertices edge, Color color) { AddTriangle(center, edge.v1, edge.v2); AddTriangleColor(color); AddTriangle(center, edge.v2, edge.v3); AddTriangleColor(color); AddTriangle(center, edge.v3, edge.v4); AddTriangleColor(color); }
Y un método para triangular una tira de cuadrángulos entre dos bordes.
void TriangulateEdgeStrip ( EdgeVertices e1, Color c1, EdgeVertices e2, Color c2 ) { AddQuad(e1.v1, e1.v2, e2.v1, e2.v2); AddQuadColor(c1, c2); AddQuad(e1.v2, e1.v3, e2.v2, e2.v3); AddQuadColor(c1, c2); AddQuad(e1.v3, e1.v4, e2.v3, e2.v4); AddQuadColor(c1, c2); }
Esto nos permitirá simplificar el método
Triangulate
.
void Triangulate (HexDirection direction, HexCell cell) { Vector3 center = cell.Position; EdgeVertices e = new EdgeVertices( center + HexMetrics.GetFirstSolidCorner(direction), center + HexMetrics.GetSecondSolidCorner(direction) ); TriangulateEdgeFan(center, e, cell.color); if (direction <= HexDirection.SE) { TriangulateConnection(direction, cell, e); } }
Pasemos a
TriangulateConnection
. Ahora podemos usar
TriangulateEdgeStrip
, pero se deben hacer otros reemplazos. Donde solíamos usar
v1
, necesitamos usar
e1.v1
. Del mismo modo,
v2
convierte en
e1.v4
,
v3
convierte en
e2.v1
y
v4
convierte en
e2.v4
.
void TriangulateConnection ( HexDirection direction, HexCell cell, EdgeVertices e1 ) { HexCell neighbor = cell.GetNeighbor(direction); if (neighbor == null) { return; } Vector3 bridge = HexMetrics.GetBridge(direction); bridge.y = neighbor.Position.y - cell.Position.y; EdgeVertices e2 = new EdgeVertices( e1.v1 + bridge, e1.v4 + bridge ); if (cell.GetEdgeType(direction) == HexEdgeType.Slope) { TriangulateEdgeTerraces(e1.v1, e1.v4, cell, e2.v1, e2.v4, neighbor); } else { TriangulateEdgeStrip(e1, cell.color, e2, neighbor.color); } HexCell nextNeighbor = cell.GetNeighbor(direction.Next()); if (direction <= HexDirection.E && nextNeighbor != null) { Vector3 v5 = e1.v4 + HexMetrics.GetBridge(direction.Next()); v5.y = nextNeighbor.Position.y; if (cell.Elevation <= neighbor.Elevation) { if (cell.Elevation <= nextNeighbor.Elevation) { TriangulateCorner( e1.v4, cell, e2.v4, neighbor, v5, nextNeighbor ); } else { TriangulateCorner( v5, nextNeighbor, e1.v4, cell, e2.v4, neighbor ); } } else if (neighbor.Elevation <= nextNeighbor.Elevation) { TriangulateCorner( e2.v4, neighbor, v5, nextNeighbor, e1.v4, cell ); } else { TriangulateCorner( v5, nextNeighbor, e1.v4, cell, e2.v4, neighbor ); } }
División de la repisa
Necesitamos dividir las repisas. Por lo tanto, pasamos los bordes a
TriangulateEdgeTerraces
.
if (cell.GetEdgeType(direction) == HexEdgeType.Slope) { TriangulateEdgeTerraces(e1, cell, e2, neighbor); }
Ahora necesitamos modificar
TriangulateEdgeTerraces
para que se interpole entre aristas y no entre pares de vértices. Supongamos que
EdgeVertices
tiene un método estático conveniente para hacer esto. Esto nos permitirá simplificar
TriangulateEdgeTerraces
lugar de complicarlo.
void TriangulateEdgeTerraces ( EdgeVertices begin, HexCell beginCell, EdgeVertices end, HexCell endCell ) { EdgeVertices e2 = EdgeVertices.TerraceLerp(begin, end, 1); Color c2 = HexMetrics.TerraceLerp(beginCell.color, endCell.color, 1); TriangulateEdgeStrip(begin, beginCell.color, e2, c2); for (int i = 2; i < HexMetrics.terraceSteps; i++) { EdgeVertices e1 = e2; Color c1 = c2; e2 = EdgeVertices.TerraceLerp(begin, end, i); c2 = HexMetrics.TerraceLerp(beginCell.color, endCell.color, i); TriangulateEdgeStrip(e1, c1, e2, c2); } TriangulateEdgeStrip(e2, c2, end, endCell.color); }
El método
EdgeVertices.TerraceLerp
simplemente interpola las repisas entre los cuatro pares de vértices de dos bordes.
public static EdgeVertices TerraceLerp ( EdgeVertices a, EdgeVertices b, int step) { EdgeVertices result; result.v1 = HexMetrics.TerraceLerp(a.v1, b.v1, step); result.v2 = HexMetrics.TerraceLerp(a.v2, b.v2, step); result.v3 = HexMetrics.TerraceLerp(a.v3, b.v3, step); result.v4 = HexMetrics.TerraceLerp(a.v4, b.v4, step); return result; }
Repisas subdivididas.paquete de la unidadReconecta acantilados y repisas
Hasta ahora, hemos ignorado las grietas en la unión de acantilados y repisas. Es hora de resolver este problema. Primero veamos los casos de acantilado-pendiente-pendiente (OSS) y pendiente-acantilado-pendiente (SOS).
Agujeros de malla.El problema surge porque las partes superiores de las fronteras se han movido. Esto significa que ahora no se encuentran exactamente del lado del acantilado, lo que conduce a una grieta. A veces estos agujeros son invisibles, y a veces llamativos.
La solución es no mover la parte superior del borde. Esto significa que necesitamos controlar si el punto se moverá. La forma más fácil sería crear una alternativa
AddTriangle
que no mueva los vértices en absoluto.
void AddTriangleUnperturbed (Vector3 v1, Vector3 v2, Vector3 v3) { int vertexIndex = vertices.Count; vertices.Add(v1); vertices.Add(v2); vertices.Add(v3); triangles.Add(vertexIndex); triangles.Add(vertexIndex + 1); triangles.Add(vertexIndex + 2); }
Cambie el
TriangulateBoundaryTriangle
para que use este método. Esto significa que tendrá que mover explícitamente todos los vértices, excepto los límites.
void TriangulateBoundaryTriangle ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 boundary, Color boundaryColor ) { Vector3 v2 = HexMetrics.TerraceLerp(begin, left, 1); Color c2 = HexMetrics.TerraceLerp(beginCell.color, leftCell.color, 1); AddTriangleUnperturbed(Perturb(begin), Perturb(v2), boundary); AddTriangleColor(beginCell.color, c2, boundaryColor); for (int i = 2; i < HexMetrics.terraceSteps; i++) { Vector3 v1 = v2; Color c1 = c2; v2 = HexMetrics.TerraceLerp(begin, left, i); c2 = HexMetrics.TerraceLerp(beginCell.color, leftCell.color, i); AddTriangleUnperturbed(Perturb(v1), Perturb(v2), boundary); AddTriangleColor(c1, c2, boundaryColor); } AddTriangleUnperturbed(Perturb(v2), Perturb(left), boundary); AddTriangleColor(c2, leftCell.color, boundaryColor); }
Vale la pena señalar lo siguiente: como no usamos
v2
para obtener otro punto, podemos moverlo de inmediato. Esta es una optimización simple y reduce la cantidad de código, así que vamos a presentarlo.
void TriangulateBoundaryTriangle ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 boundary, Color boundaryColor ) { Vector3 v2 = Perturb(HexMetrics.TerraceLerp(begin, left, 1)); Color c2 = HexMetrics.TerraceLerp(beginCell.color, leftCell.color, 1); AddTriangleUnperturbed(Perturb(begin), v2, boundary); AddTriangleColor(beginCell.color, c2, boundaryColor); for (int i = 2; i < HexMetrics.terraceSteps; i++) { Vector3 v1 = v2; Color c1 = c2; v2 = Perturb(HexMetrics.TerraceLerp(begin, left, i)); c2 = HexMetrics.TerraceLerp(beginCell.color, leftCell.color, i); AddTriangleUnperturbed(v1, v2, boundary); AddTriangleColor(c1, c2, boundaryColor); } AddTriangleUnperturbed(v2, Perturb(left), boundary); AddTriangleColor(c2, leftCell.color, boundaryColor); }
Fronteras inmóviles.Se ve mejor, pero aún no hemos terminado. Dentro del método
TriangulateCornerTerracesCliff
, el punto límite se interpola entre los puntos izquierdo y derecho. Sin embargo, estos puntos aún no se han movido. Para que el punto límite corresponda con el acantilado resultante, necesitamos interpolar entre los puntos movidos.
Vector3 boundary = Vector3.Lerp(Perturb(begin), Perturb(right), b);
Lo mismo es cierto para el método
TriangulateCornerCliffTerraces
.
Vector3 boundary = Vector3.Lerp(Perturb(begin), Perturb(left), b);
Los agujeros se han ido.Acantilados dobles y pendiente
En todos los casos problemáticos restantes, dos acantilados y una pendiente están presentes.
Gran agujero debido a un solo triángulo.Este problema se resuelve moviendo manualmente un solo triángulo en el bloque
else
al final de
TriangulateCornerTerracesCliff
.
else { AddTriangleUnperturbed(Perturb(left), Perturb(right), boundary); AddTriangleColor(leftCell.color, rightCell.color, boundaryColor); }
Lo mismo ocurre con
TriangulateCornerCliffTerraces
.
else { AddTriangleUnperturbed(Perturb(left), Perturb(right), boundary); AddTriangleColor(leftCell.color, rightCell.color, boundaryColor); }
Deshágase de las últimas grietas.paquete de la unidadFinalización
Ahora tenemos una malla distorsionada completamente correcta. Su apariencia depende del ruido específico, su escala y las fuerzas de distorsión. En nuestro caso, la distorsión puede parecer demasiado fuerte. Aunque este desnivel se ve hermoso, no queremos que las celdas se desvíen demasiado de la cuadrícula uniforme. Al final, todavía lo usamos para definir la celda a cambiar de tamaño. Y si el tamaño de las celdas varía demasiado, entonces será más difícil para nosotros colocar el contenido en ellas.Mallas no distorsionadas y distorsionadas.Parece que la fuerza 5 para distorsionar las células es demasiado grande.La distorsión de las celdas es de 0 a 5.Reduzcamos a 4 para aumentar la conveniencia de la cuadrícula, sin que sea demasiado correcta. Esto asegura que el desplazamiento máximo de XZ será √32 ≈ 5.66 unidades. public const float cellPerturbStrength = 4f;
Fuerza de distorsión celular 4.Otro valor que se puede cambiar es el coeficiente de integridad. Si lo aumentamos, los centros planos de las celdas se agrandarán, es decir, habrá más espacio para contenido futuro. Por supuesto, al hacerlo se volverán más hexagonales.Coeficiente de integridad de 0,75 a 0,95.Un ligero aumento en el coeficiente de integridad a 0.8 simplificará ligeramente nuestra vida en el futuro. public const float solidFactor = 0.8f;
Coeficiente de integridad 0.8.Finalmente, puede notar que las diferencias entre los niveles de elevación son demasiado agudas. Esto es conveniente cuando necesita asegurarse de que la malla se genera correctamente, pero ya hemos terminado con esto. Vamos a reducirlo a 1 unidad por paso, es decir, a 3. public const float elevationStep = 3f;
El tono se reduce a 3.También podemos cambiar la intensidad de la distorsión del tono. Pero ahora tiene un valor de 1.5, que es igual a medio paso de altura, lo que nos conviene.Pequeños pasos de altura permiten un uso más lógico de los siete niveles de altura. Esto aumenta la variabilidad del mapa.Usamos siete niveles de altura.paquete de la unidadParte 5: tarjetas más grandes
- Dividimos la cuadrícula en fragmentos.
- Nosotros controlamos la cámara.
- Colorea los colores y las alturas por separado.
- Usa el pincel ampliado de las celdas.
Hasta ahora hemos estado trabajando con un mapa muy pequeño. Es hora de aumentarlo.Es hora de acercarse.Fragmentos de malla
No podemos hacer que la cuadrícula sea demasiado grande, porque nos topamos con los límites de lo que cabe en una malla. ¿Cómo resolver este problema? Usa múltiples mallas. Para hacer esto, necesitamos dividir nuestra cuadrícula en varios fragmentos. Utilizamos fragmentos rectangulares de tamaño constante.Dividiendo la cuadrícula en 3 por 3 segmentos.Usemos 5 por 5 bloques, es decir, 25 celdas por fragmento. Defínalos en HexMetrics
. public const int chunkSizeX = 5, chunkSizeZ = 5;
¿Qué tamaño de fragmento puede considerarse adecuado?. , . . , (frustum culling), . .
Ahora no podemos usar ningún tamaño para la malla; debe ser un múltiplo del tamaño del fragmento. Por lo tanto, cambiemos HexGrid
para que establezca su tamaño no en celdas separadas, sino en fragmentos. Establezca el tamaño predeterminado en 4 por 3 fragmentos, es decir, solo 12 fragmentos o 300 celdas. Entonces obtenemos una tarjeta de prueba conveniente. public int chunkCountX = 4, chunkCountZ = 3;
Todavía usamos width
y height
, pero ahora deberían volverse privados. Y renómbralos a cellCountX
y cellCountZ
. Use el editor para cambiar el nombre de todas las apariciones de estas variables a la vez. Ahora quedará claro cuando tratemos con la cantidad de fragmentos o células.
Especifique el tamaño en fragmentos.Cambie Awake
para que, si es necesario, el número de celdas se calcule a partir del número de fragmentos. Destacamos la creación de celdas en un método separado, para no obstruir Awake
. void Awake () { HexMetrics.noiseSource = noiseSource; gridCanvas = GetComponentInChildren<Canvas>(); hexMesh = GetComponentInChildren<HexMesh>(); cellCountX = chunkCountX * HexMetrics.chunkSizeX; cellCountZ = chunkCountZ * HexMetrics.chunkSizeZ; CreateCells(); } void CreateCells () { cells = new HexCell[cellCountZ * cellCountX]; for (int z = 0, i = 0; z < cellCountZ; z++) { for (int x = 0; x < cellCountX; x++) { CreateCell(x, z, i++); } } }
Fragmento prefabricado
Para describir los fragmentos de malla, necesitamos un nuevo tipo de componente. using UnityEngine; using UnityEngine.UI; public class HexGridChunk : MonoBehaviour { }
A continuación crearemos un fragmento prefabricado. Haremos esto duplicando el objeto Hex Grid y renombrándolo como Hex Grid Chunk . Elimine su componente HexGrid
y agregue un componente en su lugar HexGridChunk
. Luego conviértalo en una casa prefabricada y elimine el objeto de la escena.Un fragmento prefabricado con su propio lienzo y malla.Como creará instancias de estos fragmentos HexGrid
, le daremos un enlace a la prefabricación del fragmento. public HexGridChunk chunkPrefab;
Ahora con fragmentos.Crear instancias de fragmentos es muy parecido a crear instancias de celdas. Los rastrearemos con la ayuda de una matriz y usaremos un bucle doble para llenarlo. HexGridChunk[] chunks; void Awake () { … CreateChunks(); CreateCells(); } void CreateChunks () { chunks = new HexGridChunk[chunkCountX * chunkCountZ]; for (int z = 0, i = 0; z < chunkCountZ; z++) { for (int x = 0; x < chunkCountX; x++) { HexGridChunk chunk = chunks[i++] = Instantiate(chunkPrefab); chunk.transform.SetParent(transform); } } }
Inicializar un fragmento es similar a cómo inicializamos una cuadrícula de hexágonos. Ella establece todo Awake
y realiza la triangulación Start
. Requiere una referencia a su lienzo y malla, así como una matriz para las celdas. Sin embargo, el fragmento no creará estas celdas. La cuadrícula continuará haciendo esto. public class HexGridChunk : MonoBehaviour { HexCell[] cells; HexMesh hexMesh; Canvas gridCanvas; void Awake () { gridCanvas = GetComponentInChildren<Canvas>(); hexMesh = GetComponentInChildren<HexMesh>(); cells = new HexCell[HexMetrics.chunkSizeX * HexMetrics.chunkSizeZ]; } void Start () { hexMesh.Triangulate(cells); } }
Asignación de celdas a fragmentos
HexGrid
Todavía crea todas las celdas. Esto es normal, pero ahora necesitamos agregar cada celda a un fragmento adecuado y no configurarlas con nuestra propia malla y lienzo. void CreateCell (int x, int z, int i) { … HexCell cell = cells[i] = Instantiate<HexCell>(cellPrefab);
Podemos encontrar el fragmento correcto usando la división de enteros x
y z
por tamaño de fragmento. void AddCellToChunk (int x, int z, HexCell cell) { int chunkX = x / HexMetrics.chunkSizeX; int chunkZ = z / HexMetrics.chunkSizeZ; HexGridChunk chunk = chunks[chunkX + chunkZ * chunkCountX]; }
Usando resultados intermedios, también podemos determinar el índice local de la célula en este fragmento. Después de eso, puede agregar una celda al fragmento. void AddCellToChunk (int x, int z, HexCell cell) { int chunkX = x / HexMetrics.chunkSizeX; int chunkZ = z / HexMetrics.chunkSizeZ; HexGridChunk chunk = chunks[chunkX + chunkZ * chunkCountX]; int localX = x - chunkX * HexMetrics.chunkSizeX; int localZ = z - chunkZ * HexMetrics.chunkSizeZ; chunk.AddCell(localX + localZ * HexMetrics.chunkSizeX, cell); }
Luego HexGridChunk.AddCell
coloca la celda en su propia matriz, y luego establece los elementos principales para la celda y su interfaz de usuario. public void AddCell (int index, HexCell cell) { cells[index] = cell; cell.transform.SetParent(transform, false); cell.uiRect.SetParent(gridCanvas.transform, false); }
Barrer
En este punto, HexGrid
puede deshacerse del lienzo y la malla hexagonal de sus hijos, así como del código.
Desde que nos deshicimos Refresh
, ya HexMapEditor
no deberíamos usarlo. void EditCell (HexCell cell) { cell.color = activeColor; cell.Elevation = activeElevation;
La rejilla limpia de hexágonos.Después de iniciar el modo de juego, la tarjeta todavía se ve igual. Pero la jerarquía de los objetos será diferente. Hex Grid ahora crea fragmentos de objetos secundarios que contienen celdas, así como su malla y lienzo.Fragmentos de niños en modo Play.Quizás tengamos algunos problemas con las etiquetas de las celdas. Inicialmente, configuramos el ancho de la etiqueta en 5. Esto fue suficiente para mostrar los dos caracteres que eran suficientes para nosotros en un pequeño mapa. Pero ahora podemos tener coordenadas como −10, en las que hay tres caracteres. No encajarán y se recortarán. Para solucionar esto, aumente el ancho de la etiqueta de la celda a 10, o incluso más.Etiquetas de celda extendidas.¡Ahora podemos crear mapas mucho más grandes! Dado que generamos la cuadrícula completa al inicio, puede llevar mucho tiempo crear mapas grandes. Pero después de la finalización, tendremos un gran espacio para la experimentación.Fix cell edit
La edición no parece funcionar en la etapa actual, porque ya no actualizamos la cuadrícula. Necesitamos actualizar fragmentos individuales, así que agregue un método Refresh
a HexGridChunk
. public void Refresh () { hexMesh.Triangulate(cells); }
¿Cuándo deberíamos llamar a este método? Actualizamos toda la cuadrícula cada vez porque solo teníamos una malla. Pero ahora tenemos muchos fragmentos. En lugar de actualizarlos todos cada vez, será mucho más eficiente actualizar los fragmentos modificados. De lo contrario, cambiar las tarjetas grandes se convertirá en una operación muy lenta.Pero, ¿cómo sabemos qué fragmento actualizar? La forma más fácil es hacer que cada célula sepa a qué fragmento pertenece. Entonces la celda podrá actualizar su fragmento al cambiar esta celda. Entonces, demos un HexCell
enlace a su fragmento. public HexGridChunk chunk;
HexGridChunk
puede agregarse a la celda al agregar. public void AddCell (int index, HexCell cell) { cells[index] = cell; cell.chunk = this; cell.transform.SetParent(transform, false); cell.uiRect.SetParent(gridCanvas.transform, false); }
Al conectarlos, agregamos al HexCell
método Refresh
. Cada vez que se actualiza una celda, simplemente actualizará su fragmento. void Refresh () { chunk.Refresh(); }
No necesitamos hacerlo HexCell.Refresh
común, porque la célula en sí misma sabe mejor cuando se cambió. Por ejemplo, después de que se haya cambiado su altura. public int Elevation { get { return elevation; } set { … Refresh(); } }
De hecho, necesitamos actualizarlo solo cuando su altura ha cambiado a un valor diferente. Ni siquiera necesita volver a calcular nada si le asignamos la misma altura que antes. Por lo tanto, podemos salir del comienzo del setter. public int Elevation { get { return elevation; } set { if (elevation == value) { return; } … } }
Sin embargo, también omitiremos los cálculos por primera vez cuando la altura se establezca en 0, porque este es el valor predeterminado de altura de malla. Para evitar esto, haremos el valor inicial tal como nunca lo usamos. int elevation = int.MinValue;
¿Qué es int.MinValue?, integer. C# integer —
32- , 2 32 integer, , . .
— −2 31 = −2 147 483 648. !
2 31 − 1 = 2 147 483 647. 2 31 - .
Para reconocer el cambio de color de la celda, también debemos convertirlo en una propiedad. Cambie el nombre a Color
mayúsculas y luego conviértalo en una propiedad con una variable privada color
. El valor de color predeterminado será negro transparente, lo que nos conviene. public Color Color { get { return color; } set { if (color == value) { return; } color = value; Refresh(); } } Color color;
Ahora, cuando comenzamos el modo Play, obtenemos excepciones de referencia nula. Esto sucede porque establecemos el color y la altura a sus valores predeterminados antes de asignar una celda a su fragmento. Es normal que no actualicemos los fragmentos en esta etapa, porque los triangulamos después de que se complete toda la inicialización. En otras palabras, actualizamos un fragmento solo si está asignado. void Refresh () { if (chunk) { chunk.Refresh(); } }
¡Finalmente podemos cambiar las células nuevamente! Sin embargo, surge un problema. Al dibujar a lo largo de los bordes de los fragmentos, aparecen las costuras.Errores en los límites de los fragmentos.Esto es lógico, porque cuando cambia una sola celda, todas las conexiones con sus vecinos también cambian. Y estos vecinos pueden estar en otros fragmentos. La solución más simple es actualizar todas las celdas vecinas si son diferentes. void Refresh () { if (chunk) { chunk.Refresh(); for (int i = 0; i < neighbors.Length; i++) { HexCell neighbor = neighbors[i]; if (neighbor != null && neighbor.chunk != chunk) { neighbor.chunk.Refresh(); } } } }
Aunque esto funciona, puede resultar que actualicemos un fragmento varias veces. Y cuando comenzamos a colorear varias celdas a la vez, todo empeorará.Pero no estamos obligados a triangular inmediatamente después de actualizar el fragmento. En cambio, simplemente escribimos que se necesita una actualización y triangulamos después de que se completa el cambio.Como no HexGridChunk
hace nada más, podemos usar su estado habilitado para indicar la necesidad de actualizaciones. Al actualizarlo, incluimos el componente. Encenderlo varias veces no cambiará nada. El componente se actualiza más tarde. Triangularemos en este punto y deshabilitaremos el componente nuevamente.Usamos en su LateUpdate
lugarUpdate
para garantizar que se produce la triangulación después de completar el cambio para el marco actual. public void Refresh () {
¿Cuál es la diferencia entre Update y LateUpdate?Update
- . LateUpdate
. , .
Dado que nuestro componente está habilitado por defecto, ya no necesitamos triangular explícitamente Start
. Por lo tanto, este método puede eliminarse.
Fragmentos de 20 por 20 que contienen 10,000 células.Listas generalizadas
Aunque hemos cambiado significativamente la forma en que se triangula la cuadrícula, HexMesh
sigue siendo la misma. Todo lo que necesita para trabajar es una matriz de células. No le importa si hay una malla de hexágonos, o varias. Pero aún no hemos considerado usar múltiples mallas. Tal vez algo se puede mejorar aquí?Las HexMesh
listas utilizadas son esencialmente memorias intermedias temporales. Se usan solo para triangulación. Y los fragmentos se triangulan uno a la vez. Por lo tanto, de hecho, solo necesitamos un conjunto de listas, y no un conjunto para cada objeto de malla hexagonal. Esto se puede lograr haciendo que las listas sean estáticas. static List<Vector3> vertices = new List<Vector3>(); static List<Color> colors = new List<Color>(); static List<int> triangles = new List<int>(); void Awake () { GetComponent<MeshFilter>().mesh = hexMesh = new Mesh(); meshCollider = gameObject.AddComponent<MeshCollider>(); hexMesh.name = "Hex Mesh";
¿Son realmente tan importantes las listas estáticas?. , , .
, . 20 20 100.
paquete de la unidadControl de la cámara
La cámara grande es maravillosa, pero es inútil si no podemos verla. Para inspeccionar todo el mapa, necesitamos mover la cámara. El zoom también es útil. Por lo tanto, creemos una cámara para realizar estas acciones.Crea un objeto ficticio y llámalo Hex Map Camera . Suelte su componente de transformación para que se mueva al origen sin cambiar su rotación y escala. Agregue un niño llamado Giratorio y agregue un niño Stick . Convierta la cámara principal en un elemento secundario del Stick y restablezca su componente de transformación.La jerarquía de la cámara.El objetivo de la bisagra de la cámara (Giratorio) es controlar el ángulo en el que la cámara mira el mapa. Vamos a darle una vuelta (45, 0, 0). El asa (Stick) controla la distancia a la que se encuentran las cámaras. Vamos a ponerle una posición (0, 0, -45).Ahora necesitamos un componente para controlar este sistema. Asigne este componente a la raíz de la jerarquía de la cámara. Dale un enlace a la bisagra y al mango, introduciéndolos Awake
. using UnityEngine; public class HexMapCamera : MonoBehaviour { Transform swivel, stick; void Awake () { swivel = transform.GetChild(0); stick = swivel.GetChild(0); } }
Cámara de mapa hexagonal.Zoom
La primera función que crearemos es el zoom (zoom). Podemos controlar el nivel de zoom actual usando la variable flotante. Un valor de 0 significa que estamos completamente distantes, y un valor de 1 significa que estamos completamente cerca. Comencemos con el zoom máximo. float zoom = 1f;
El zoom generalmente se realiza con la rueda del mouse o el control analógico. Podemos implementarlo usando el eje de entrada predeterminado de Mouse ScrollWheel . Agregue un método Update
que verifique la presencia de un delta de entrada y, si existe, llama al método de cambio de zoom. void Update () { float zoomDelta = Input.GetAxis("Mouse ScrollWheel"); if (zoomDelta != 0f) { AdjustZoom(zoomDelta); } } void AdjustZoom (float delta) { }
Para cambiar el nivel de zoom, simplemente le agregamos un delta y luego limitamos el valor (pinza) para permanecer en el rango 0-1. void AdjustZoom (float delta) { zoom = Mathf.Clamp01(zoom + delta); }
Al acercar y alejar, la distancia a la cámara debe cambiar en consecuencia. Esto se puede hacer cambiando la posición del controlador en Z. Agregue dos variables flotantes comunes para ajustar la posición del controlador con el zoom mínimo y máximo. Como estamos desarrollando un mapa relativamente pequeño, establezca los valores en -250 y -45. public float stickMinZoom, stickMaxZoom;
Después de cambiar el zoom, realizamos una interpolación lineal entre estos dos valores en función del nuevo valor de zoom. Luego actualice la posición del mango. void AdjustZoom (float delta) { zoom = Mathf.Clamp01(zoom + delta); float distance = Mathf.Lerp(stickMinZoom, stickMaxZoom, zoom); stick.localPosition = new Vector3(0f, 0f, distance); }
Valores mínimos y máximos de Stick.Ahora el zoom funciona, pero hasta ahora no es muy útil. Por lo general, cuando el zoom está más lejos, la cámara entra en una vista superior. Podemos darnos cuenta de esto girando la bisagra. Por lo tanto, agregamos las variables min y max para la bisagra. Vamos a establecer los valores 90 y 45. public float swivelMinZoom, swivelMaxZoom;
Al igual que con la posición del mango, interpolamos para encontrar un ángulo de zoom adecuado. Luego establecemos la rotación de la bisagra. void AdjustZoom (float delta) { zoom = Mathf.Clamp01(zoom + delta); float distance = Mathf.Lerp(stickMinZoom, stickMaxZoom, zoom); stick.localPosition = new Vector3(0f, 0f, distance); float angle = Mathf.Lerp(swivelMinZoom, swivelMaxZoom, zoom); swivel.localRotation = Quaternion.Euler(angle, 0f, 0f); }
El valor mínimo y máximo de Swivel.La tasa de cambio del zoom se puede ajustar cambiando la sensibilidad de los parámetros de entrada de la rueda del mouse. Se pueden encontrar en Edición / Configuración del proyecto / Entrada . Por ejemplo, al cambiarlos de 0.1 a 0.025, obtenemos un cambio de zoom más lento y suave.Opciones de entrada de la rueda del mouse.En movimiento
Ahora pasemos a mover la cámara. El movimiento en la dirección de X y Z debemos implementarlo Update
, como en el caso del zoom. Podemos usar ejes de entrada horizontal y vertical para esto . Esto nos permitirá mover la cámara con las flechas y las teclas WASD. void Update () { float zoomDelta = Input.GetAxis("Mouse ScrollWheel"); if (zoomDelta != 0f) { AdjustZoom(zoomDelta); } float xDelta = Input.GetAxis("Horizontal"); float zDelta = Input.GetAxis("Vertical"); if (xDelta != 0f || zDelta != 0f) { AdjustPosition(xDelta, zDelta); } } void AdjustPosition (float xDelta, float zDelta) { }
El enfoque más simple es obtener la posición actual del sistema de cámara, agregarle deltas X y Z, y asignar el resultado a la posición del sistema. void AdjustPosition (float xDelta, float zDelta) { Vector3 position = transform.localPosition; position += new Vector3(xDelta, 0f, zDelta); transform.localPosition = position; }
Debido a esto, la cámara se moverá mientras mantiene presionadas las flechas o WASD, pero no a una velocidad constante. Dependerá de la velocidad de fotogramas. Para determinar la distancia que necesita moverse, usamos el tiempo delta, así como la velocidad requerida. Por lo tanto, agregamos una variable común moveSpeed
y la establecemos en 100, y luego la multiplicamos por el delta de tiempo para obtener la posición delta. public float moveSpeed; void AdjustPosition (float xDelta, float zDelta) { float distance = moveSpeed * Time.deltaTime; Vector3 position = transform.localPosition; position += new Vector3(xDelta, 0f, zDelta) * distance; transform.localPosition = position; }
Velocidad de movimiento.Ahora podemos movernos a una velocidad constante a lo largo de los ejes X o Z. Pero cuando nos movemos a lo largo de ambos ejes al mismo tiempo (en diagonal) el movimiento será más rápido. Para solucionar esto, necesitamos normalizar el vector delta. Esto le permitirá usarlo como destino. void AdjustPosition (float xDelta, float zDelta) { Vector3 direction = new Vector3(xDelta, 0f, zDelta).normalized; float distance = moveSpeed * Time.deltaTime; Vector3 position = transform.localPosition; position += direction * distance; transform.localPosition = position; }
El movimiento diagonal ahora se implementa correctamente, pero de repente resulta que la cámara continúa moviéndose durante un tiempo bastante largo incluso después de soltar todas las teclas. Esto sucede porque los ejes de entrada no saltan instantáneamente a los valores límite inmediatamente después de presionar las teclas. Necesitan algo de tiempo para esto. Lo mismo es cierto para la liberación de claves. Lleva tiempo volver a los valores del eje cero. Sin embargo, dado que normalizamos los valores de entrada, la velocidad máxima se mantiene constantemente.Podemos ajustar los parámetros de entrada para eliminar los retrasos, pero dan una sensación de suavidad que vale la pena guardar. Podemos aplicar el valor más extremo de los ejes como el coeficiente de movimiento de amortiguación. Vector3 direction = new Vector3(xDelta, 0f, zDelta).normalized; float damping = Mathf.Max(Mathf.Abs(xDelta), Mathf.Abs(zDelta)); float distance = moveSpeed * damping * Time.deltaTime;
Movimiento con atenuación.Ahora el movimiento funciona bien, al menos con un aumento en el zoom. Pero a cierta distancia resulta ser demasiado lento. Con el zoom reducido, necesitamos acelerar. Esto se puede hacer reemplazando una variable moveSpeed
con dos para un zoom mínimo y máximo, y luego interpolando. Asignarles valores de 400 y 100.
La velocidad de movimiento varía con el nivel de zoom.¡Ahora podemos movernos rápidamente por el mapa! De hecho, podemos movernos mucho más allá del mapa, pero esto no es deseable. La cámara debe permanecer dentro del mapa. Para garantizar esto, necesitamos conocer los límites del mapa, por lo que se necesita un enlace a la cuadrícula. Agregar y conectarlo. public HexGrid grid;
Necesita solicitar el tamaño de la cuadrícula.Después de pasar a una nueva posición, la limitaremos usando el nuevo método. void AdjustPosition (float xDelta, float zDelta) { … transform.localPosition = ClampPosition(position); } Vector3 ClampPosition (Vector3 position) { return position; }
La posición X tiene un valor mínimo de 0, y el máximo está determinado por el tamaño del mapa. Vector3 ClampPosition (Vector3 position) { float xMax = grid.chunkCountX * HexMetrics.chunkSizeX * (2f * HexMetrics.innerRadius); position.x = Mathf.Clamp(position.x, 0f, xMax); return position; }
Lo mismo se aplica a la posición Z. Vector3 ClampPosition (Vector3 position) { float xMax = grid.chunkCountX * HexMetrics.chunkSizeX * (2f * HexMetrics.innerRadius); position.x = Mathf.Clamp(position.x, 0f, xMax); float zMax = grid.chunkCountZ * HexMetrics.chunkSizeZ * (1.5f * HexMetrics.outerRadius); position.z = Mathf.Clamp(position.z, 0f, zMax); return position; }
De hecho, esto es un poco inexacto. El punto de partida está en el centro de la celda, no a la izquierda. Por lo tanto, queremos que la cámara se detenga en el centro de las celdas más a la derecha. Para hacer esto, reste la mitad de la celda del máximo de X. float xMax = (grid.chunkCountX * HexMetrics.chunkSizeX - 0.5f) * (2f * HexMetrics.innerRadius); position.x = Mathf.Clamp(position.x, 0f, xMax);
Por la misma razón, necesitamos reducir la Z máxima. Dado que las métricas son ligeramente diferentes, necesitamos restar la celda completa. float zMax = (grid.chunkCountZ * HexMetrics.chunkSizeZ - 1) * (1.5f * HexMetrics.outerRadius); position.z = Mathf.Clamp(position.z, 0f, zMax);
Con el movimiento que hemos terminado, solo queda un pequeño detalle. A veces, la interfaz de usuario reacciona a las teclas de flecha, y esto lleva al hecho de que cuando mueve la cámara, el control deslizante se mueve. Esto sucede cuando la IU se considera activa, después de hacer clic en ella y el cursor continúa sobre ella.Puede evitar que la IU escuche la entrada del teclado. Esto se puede hacer instruyendo al objeto EventSystem para que no ejecute Enviar eventos de navegación .No más eventos de navegación.Girar
¿Quieres ver qué hay detrás del acantilado? ¡Sería conveniente poder girar la cámara! Agreguemos esta característica.El nivel de zoom no es importante para la rotación, solo la velocidad es suficiente. Agregue una variable común rotationSpeed
y configúrela en 180 grados. Verifique el delta de rotación Update
al muestrear el eje de rotación y cambiar la rotación si es necesario. public float rotationSpeed; void Update () { float zoomDelta = Input.GetAxis("Mouse ScrollWheel"); if (zoomDelta != 0f) { AdjustZoom(zoomDelta); } float rotationDelta = Input.GetAxis("Rotation"); if (rotationDelta != 0f) { AdjustRotation(rotationDelta); } float xDelta = Input.GetAxis("Horizontal"); float zDelta = Input.GetAxis("Vertical"); if (xDelta != 0f || zDelta != 0f) { AdjustPosition(xDelta, zDelta); } } void AdjustRotation (float delta) { }
Velocidad de giroDe hecho, el eje de rotación no es por defecto. Tendremos que crearlo nosotros mismos. Vaya a los parámetros de entrada y duplique la entrada superior Vertical . Cambie el nombre del duplicado a Rotación y cambie las claves a QE y una coma (,) con un punto (.).Gire el eje de entrada.Descargué unitypackage, ¿por qué no tengo esta entrada?. Unity. , . , , .
El ángulo de rotación que rastrearemos y cambiaremos AdjustRotation
. Después de lo cual giraremos todo el sistema de cámara. float rotationAngle; void AdjustRotation (float delta) { rotationAngle += delta * rotationSpeed * Time.deltaTime; transform.localRotation = Quaternion.Euler(0f, rotationAngle, 0f); }
Como el círculo completo es de 360 grados, giramos el ángulo de rotación para que esté en el rango de 0 a 360. void AdjustRotation (float delta) { rotationAngle += delta * rotationSpeed * Time.deltaTime; if (rotationAngle < 0f) { rotationAngle += 360f; } else if (rotationAngle >= 360f) { rotationAngle -= 360f; } transform.localRotation = Quaternion.Euler(0f, rotationAngle, 0f); }
Conviértete en acción.Ahora la rotación está funcionando. Si lo marca, puede ver que el movimiento es absoluto. Por lo tanto, después de girar 180 grados, el movimiento será lo contrario de lo que se esperaba. Sería mucho más conveniente para el usuario que el movimiento se realice en relación con el ángulo de visión de la cámara. Podemos hacer esto multiplicando la rotación actual por la dirección del movimiento. void AdjustPosition (float xDelta, float zDelta) { Vector3 direction = transform.localRotation * new Vector3(xDelta, 0f, zDelta).normalized; … }
Desplazamiento relativo.paquete de la unidadEdición avanzada
Ahora que tenemos un mapa más grande, puede mejorar las herramientas de edición de mapas. Cambiar una celda a la vez es demasiado largo, por lo que sería bueno crear un pincel más grande. También será conveniente si puede elegir pintar o cambiar la altura, dejando todo lo demás sin cambios.Color y altura opcionales
Podemos hacer que los colores sean opcionales agregando una opción de selección vacía al grupo de alternar. Duplique uno de los conmutadores de color y reemplace su etiqueta con --- o algo similar para indicar que no es un color. Luego cambie el argumento de su evento On Value Changed a −1.Índice de color no válidoPor supuesto, este índice no es válido para una variedad de colores. Podemos usarlo para determinar si el color debe aplicarse a las celdas. bool applyColor; public void SelectColor (int index) { applyColor = index >= 0; if (applyColor) { activeColor = colors[index]; } } void EditCell (HexCell cell) { if (applyColor) { cell.Color = activeColor; } cell.Elevation = activeElevation; }
La altura está controlada por un control deslizante, por lo que no podemos agregarle un interruptor. En cambio, podemos usar un interruptor separado para habilitar o deshabilitar la edición de altura. Por defecto, estará habilitado. bool applyElevation = true; void EditCell (HexCell cell) { if (applyColor) { cell.Color = activeColor; } if (applyElevation) { cell.Elevation = activeElevation; } }
Agregue un nuevo interruptor de altura a la interfaz de usuario. También pondré todo en un nuevo panel y haré que el control deslizante de altura sea horizontal para que la interfaz de usuario sea más hermosa.Color y altura opcionales.Para habilitar la altura, necesitamos un nuevo método, que conectaremos con la interfaz de usuario. public void SetApplyElevation (bool toggle) { applyElevation = toggle; }
Al conectarlo al interruptor de altura, asegúrese de que el método bool dinámico se use en la parte superior de la lista de métodos. Las versiones correctas no muestran una marca de verificación en el inspector.Transmitimos el estado del interruptor de altura.Ahora podemos elegir solo colorear con flores o solo altura. O ambos, como siempre. Incluso podemos elegir no cambiar uno u otro, pero hasta ahora no es particularmente útil para nosotros.Cambiar entre color y altura.¿Por qué la altura se apaga al elegir un color?, toggle group. , , toggle group.
Tamaño del pincel
Para admitir el tamaño de pincel redimensionable, agregue una variable entera brushSize
y un método para configurarlo a través de la interfaz de usuario. Usaremos el control deslizante, por lo que nuevamente tendremos que convertir el valor de float a int. int brushSize; public void SetBrushSize (float size) { brushSize = (int)size; }
Control deslizante del tamaño del pincel.Puede crear un nuevo control deslizante duplicando el control deslizante de altura. Cambie su valor máximo a 4 y adjúntelo al método correspondiente. También le agregué una etiqueta.Configuración del control deslizante del tamaño del pincel.Ahora que podemos editar varias celdas al mismo tiempo, necesitamos usar el método EditCells
. Este método llamará EditCell
a todas las células involucradas. La celda seleccionada inicialmente se considerará el centro del pincel. void HandleInput () { Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(inputRay, out hit)) { EditCells(hexGrid.GetCell(hit.point)); } } void EditCells (HexCell center) { } void EditCell (HexCell cell) { … }
El tamaño del pincel determina el radio de la edición. Con un radio de 0, esta será solo una celda central. Con un radio de 1, este será el centro y sus vecinos. En un radio de 2, se activan los vecinos del centro y sus vecinos inmediatos. Y así sucesivamente.
Hasta el radio 3.Para editar celdas, debe recorrerlas en un bucle. Primero necesitamos las coordenadas X y Z del centro. void EditCells (HexCell center) { int centerX = center.coordinates.X; int centerZ = center.coordinates.Z; }
Encontramos la coordenada Z mínima restando el radio. Entonces definimos la línea cero. Comenzando en esta línea, hacemos un bucle hasta que cubrimos la línea en el centro. void EditCells (HexCell center) { int centerX = center.coordinates.X; int centerZ = center.coordinates.Z; for (int r = 0, z = centerZ - brushSize; z <= centerZ; z++, r++) { } }
La primera celda en la fila inferior tiene la misma coordenada X que la celda central. Esta coordenada disminuye al aumentar el número de línea.La última celda siempre tiene una coordenada X igual a la coordenada central más el radio.Ahora podemos recorrer cada fila y obtener celdas por sus coordenadas. for (int r = 0, z = centerZ - brushSize; z <= centerZ; z++, r++) { for (int x = centerX - r; x <= centerX + brushSize; x++) { EditCell(hexGrid.GetCell(new HexCoordinates(x, z))); } }
Todavía no tenemos un método HexGrid.GetCell
con un parámetro de coordenadas, así que créelo. Convierte a las coordenadas de los desplazamientos y obtén la celda. public HexCell GetCell (HexCoordinates coordinates) { int z = coordinates.Z; int x = coordinates.X + z / 2; return cells[x + z * cellCountX]; }
La parte inferior del pincel, tamaño 2.Cubrimos el resto del pincel, realizando un ciclo de arriba a abajo hacia el centro. En este caso, la lógica se refleja y la fila central debe excluirse. void EditCells (HexCell center) { int centerX = center.coordinates.X; int centerZ = center.coordinates.Z; for (int r = 0, z = centerZ - brushSize; z <= centerZ; z++, r++) { for (int x = centerX - r; x <= centerX + brushSize; x++) { EditCell(hexGrid.GetCell(new HexCoordinates(x, z))); } } for (int r = 0, z = centerZ + brushSize; z > centerZ; z--, r++) { for (int x = centerX - brushSize; x <= centerX + r; x++) { EditCell(hexGrid.GetCell(new HexCoordinates(x, z))); } } }
Todo el pincel, tamaño 2.Esto funciona, a menos que nuestro pincel vaya más allá de los bordes de la cuadrícula. Cuando esto sucede, obtenemos una excepción de índice fuera de rango. Para evitar esto, verifique los límites HexGrid.GetCell
y regrese null
cuando se solicite una celda inexistente. public HexCell GetCell (HexCoordinates coordinates) { int z = coordinates.Z; if (z < 0 || z >= cellCountZ) { return null; } int x = coordinates.X + z / 2; if (x < 0 || x >= cellCountX) { return null; } return cells[x + z * cellCountX]; }
Para evitar una excepción de referencia nula, HexMapEditor
debe comprobar antes de editar si la celda realmente existe. void EditCell (HexCell cell) { if (cell) { if (applyColor) { cell.Color = activeColor; } if (applyElevation) { cell.Elevation = activeElevation; } } }
Usando múltiples tamaños de pincel.Alternar visibilidad de etiqueta de celda
La mayoría de las veces, no necesitamos ver las etiquetas de las celdas. Así que hagámoslos opcionales. Como cada fragmento controla su propio lienzo, agregue un método ShowUI
a HexGridChunk
. Cuando la IU debe estar visible, activamos el lienzo. De lo contrario, desactívelo. public void ShowUI (bool visible) { gridCanvas.gameObject.SetActive(visible); }
Ocultemos la IU por defecto. void Awake () { gridCanvas = GetComponentInChildren<Canvas>(); hexMesh = GetComponentInChildren<HexMesh>(); cells = new HexCell[HexMetrics.chunkSizeX * HexMetrics.chunkSizeZ]; ShowUI(false); }
Dado que la visibilidad de la interfaz de usuario se cambia para todo el mapa, agregamos el método ShowUI
a HexGrid
. Simplemente pasa la solicitud a sus fragmentos. public void ShowUI (bool visible) { for (int i = 0; i < chunks.Length; i++) { chunks[i].ShowUI(visible); } }
HexMapEditor
obtiene el mismo método, pasando la solicitud a la cuadrícula. public void ShowUI (bool visible) { hexGrid.ShowUI(visible); }
Finalmente, podemos agregar un interruptor a la interfaz de usuario y conectarlo.Interruptor de visibilidad de etiqueta.paquete de la unidadParte 6: ríos
- Agregar ríos a las células.
- Soporte de arrastrar y soltar para dibujar ríos.
- Creando cauces.
- Usando múltiples mallas por fragmento.
- Crea un grupo de listas compartidas.
- Triangulación y animación del agua que fluye.
En la parte anterior, hablamos sobre el soporte de mapas grandes. Ahora podemos pasar a elementos de relieve más grandes. Esta vez hablaremos de los ríos.Los ríos fluyen desde las montañas.Células de los ríos
Hay tres formas de agregar ríos a una cuadrícula de hexágonos. La primera forma es dejarlos fluir de celda en celda. Así es como se implementa en Endless Legend. La segunda forma es permitirles fluir entre las celdas, de borde a borde. Por lo tanto, se implementa en Civilization 5. La tercera forma no es crear estructuras especiales de ríos, sino utilizar celdas de agua para sugerirlas. Entonces, los ríos se implementan en Age of Wonders 3.En nuestro caso, los bordes de las celdas ya están ocupados por laderas y acantilados. Esto deja poco espacio para los ríos. Por lo tanto, haremos que fluyan de una celda a otra. Esto significa que en cada celda no habrá río, o un río fluirá a lo largo de él, o habrá un principio o un final del río en él. En aquellas celdas a lo largo de las cuales fluye el río, puede fluir en línea recta, girar uno o dos pasos.Cinco posibles configuraciones fluviales.No admitiremos la ramificación o fusión de ríos. Esto complicará aún más las cosas, especialmente el flujo de agua. Además, no nos dejarán perplejos los grandes volúmenes de agua. Los consideraremos en otro tutorial.Seguimiento del río
La celda a lo largo de la cual fluye el río puede considerarse simultáneamente que tiene un río entrante y saliente. Si contiene el comienzo de un río, entonces solo tiene un río saliente. Y si contiene el final del río, entonces solo tiene un río entrante. Podemos almacenar esta información HexCell
usando dos valores booleanos. bool hasIncomingRiver, hasOutgoingRiver;
Pero esto no es suficiente. También necesitamos saber la dirección de estos ríos. En el caso de un río saliente, indica dónde se está moviendo. En el caso de un río entrante, indica de dónde vino. bool hasIncomingRiver, hasOutgoingRiver; HexDirection incomingRiver, outgoingRiver;
Necesitaremos esta información cuando triangulemos celdas, por lo que agregaremos propiedades para tener acceso a ella. No apoyaremos asignarlos directamente. Para hacer esto, agregaremos un método adicional. public bool HasIncomingRiver { get { return hasIncomingRiver; } } public bool HasOutgoingRiver { get { return hasOutgoingRiver; } } public HexDirection IncomingRiver { get { return incomingRiver; } } public HexDirection OutgoingRiver { get { return outgoingRiver; } }
Una pregunta importante es si hay un río en la celda, independientemente de los detalles. Por lo tanto, agreguemos una propiedad para esto también. public bool HasRiver { get { return hasIncomingRiver || hasOutgoingRiver; } }
Otra pregunta lógica: es el principio o el final del río en la celda. Si el estado del río entrante y saliente es diferente, entonces este es el caso. Por lo tanto, haremos de esta otra propiedad. public bool HasRiverBeginOrEnd { get { return hasIncomingRiver != hasOutgoingRiver; } }
Y finalmente, será útil saber si el río fluye a través de cierta cresta, ya sea entrante o saliente. public bool HasRiverThroughEdge (HexDirection direction) { return hasIncomingRiver && incomingRiver == direction || hasOutgoingRiver && outgoingRiver == direction; }
Remoción de ríos
Antes de comenzar a agregar un río a una celda, primero implementemos soporte para la eliminación del río. Para comenzar, escribiremos un método para eliminar solo la parte saliente del río.Si no hay un río saliente en la celda, entonces no se necesita hacer nada. De lo contrario, apáguelo y realice la actualización. public void RemoveOutgoingRiver () { if (!hasOutgoingRiver) { return; } hasOutgoingRiver = false; Refresh(); }
Pero eso no es todo. El río saliente debe moverse a alguna parte. Por lo tanto, debe haber un vecino con el río entrante. También tenemos que deshacernos de ella. public void RemoveOutgoingRiver () { if (!hasOutgoingRiver) { return; } hasOutgoingRiver = false; Refresh(); HexCell neighbor = GetNeighbor(outgoingRiver); neighbor.hasIncomingRiver = false; neighbor.Refresh(); }
¿No puede salir un río de un mapa?, . , .
Eliminar un río de una celda solo cambia la apariencia de esa celda. A diferencia de la altura o el color de edición, no afecta a los vecinos. Por lo tanto, necesitamos actualizar solo la celda en sí, pero no sus vecinos. public void RemoveOutgoingRiver () { if (!hasOutgoingRiver) { return; } hasOutgoingRiver = false; RefreshSelfOnly(); HexCell neighbor = GetNeighbor(outgoingRiver); neighbor.hasIncomingRiver = false; neighbor.RefreshSelfOnly(); }
Este método RefreshSelfOnly
simplemente actualiza el fragmento al que pertenece la celda. Como no cambiamos el río durante la inicialización de la grilla, no debemos preocuparnos si ya se ha asignado un fragmento. void RefreshSelfOnly () { chunk.Refresh(); }
Eliminar los ríos entrantes funciona de la misma manera. public void RemoveIncomingRiver () { if (!hasIncomingRiver) { return; } hasIncomingRiver = false; RefreshSelfOnly(); HexCell neighbor = GetNeighbor(incomingRiver); neighbor.hasOutgoingRiver = false; neighbor.RefreshSelfOnly(); }
Y la eliminación de todo el río simplemente significa la eliminación de las partes entrantes y salientes del río. public void RemoveRiver () { RemoveOutgoingRiver(); RemoveIncomingRiver(); }
Agregar ríos
Para apoyar la creación de ríos, necesitamos un método para especificar el río saliente de la célula. Debe redefinir todos los ríos salientes anteriores y establecer el río entrante correspondiente.Para empezar, no necesitamos hacer nada si el río ya existe. public void SetOutgoingRiver (HexDirection direction) { if (hasOutgoingRiver && outgoingRiver == direction) { return; } }
A continuación, debemos asegurarnos de que haya un vecino en la dirección correcta. Además, los ríos no pueden fluir hacia arriba. Por lo tanto, debemos completar la operación si el vecino es más alto. HexCell neighbor = GetNeighbor(direction); if (!neighbor || elevation < neighbor.elevation) { return; }
Luego tenemos que limpiar el río saliente anterior. Y también necesitamos eliminar el río entrante, si se superpone a un nuevo río saliente. RemoveOutgoingRiver(); if (hasIncomingRiver && incomingRiver == direction) { RemoveIncomingRiver(); }
Ahora podemos pasar a configurar el río de salida. hasOutgoingRiver = true; outgoingRiver = direction; RefreshSelfOnly();
Y no olvide configurar el río entrante para otra celda después de eliminar su río entrante actual, si existe. neighbor.RemoveIncomingRiver(); neighbor.hasIncomingRiver = true; neighbor.incomingRiver = direction.Opposite(); neighbor.RefreshSelfOnly();
Deshacerse de los ríos que fluyen
Ahora que hemos hecho posible agregar solo los ríos correctos, otras acciones aún pueden crear los incorrectos. Cuando cambiamos la altura de la celda, nuevamente debemos asegurarnos de que los ríos solo puedan descender. Todos los ríos irregulares deben ser eliminados. public int Elevation { get { return elevation; } set { … if ( hasOutgoingRiver && elevation < GetNeighbor(outgoingRiver).elevation ) { RemoveOutgoingRiver(); } if ( hasIncomingRiver && elevation > GetNeighbor(incomingRiver).elevation ) { RemoveIncomingRiver(); } Refresh(); } }
paquete de la unidadCambiar ríos
Para admitir la edición de ríos, debemos agregar un interruptor de río a la interfaz de usuario. De hecho Necesitamos soporte para tres modos de edición. Necesitamos ignorar los ríos, agregarlos o eliminarlos. Podemos usar una simple enumeración auxiliar de conmutadores para rastrear el estado. Como lo usaremos solo dentro del editor, podemos definirlo dentro de la clase HexMapEditor
, junto con el campo del modo río. enum OptionalToggle { Ignore, Yes, No } OptionalToggle riverMode;
Y necesitamos un método para cambiar el régimen del río a través de la interfaz de usuario. public void SetRiverMode (int mode) { riverMode = (OptionalToggle)mode; }
Para controlar el régimen del río, agregue tres interruptores a la interfaz de usuario y conéctelos al nuevo grupo de alternancia, como hicimos con los colores. Configuré los interruptores para que sus etiquetas estén debajo de las casillas de verificación. Debido a esto, seguirán siendo lo suficientemente delgadas como para adaptarse a las tres opciones en una línea.Ríos UI¿Por qué no usar una lista desplegable?, . dropdown list Unity Play. , .
Arrastrar y soltar reconocimiento
Para crear un río, necesitamos tanto una celda como una dirección. Por el momento, HexMapEditor
no nos proporciona esta información. Por lo tanto, necesitamos agregar soporte para arrastrar y soltar de una celda a otra.Necesitamos saber si este arrastre será correcto y también determinar su dirección. Y para reconocer el arrastrar y soltar, debemos recordar la celda anterior. bool isDrag; HexDirection dragDirection; HexCell previousCell;
Inicialmente, cuando no se realiza el arrastre, la celda anterior no. Es decir, cuando no hay entrada o no interactuamos con la tarjeta, debe asignarle un valor null
. void Update () { if ( Input.GetMouseButton(0) && !EventSystem.current.IsPointerOverGameObject() ) { HandleInput(); } else { previousCell = null; } } void HandleInput () { Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(inputRay, out hit)) { EditCells(hexGrid.GetCell(hit.point)); } else { previousCell = null; } }
La celda actual es la que encontramos al cruzar el haz con la malla. Después de editar celdas, se actualiza y se convierte en la celda anterior para una nueva actualización. void HandleInput () { Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(inputRay, out hit)) { HexCell currentCell = hexGrid.GetCell(hit.point); EditCells(currentCell); previousCell = currentCell; } else { previousCell = null; } }
Después de determinar la celda actual, podemos compararla con la celda anterior, si la hay. Si obtenemos dos celdas diferentes, es posible que tengamos la función de arrastrar y soltar correcta y necesitamos verificar esto. De lo contrario, definitivamente no se trata de arrastrar y soltar. if (Physics.Raycast(inputRay, out hit)) { HexCell currentCell = hexGrid.GetCell(hit.point); if (previousCell && previousCell != currentCell) { ValidateDrag(currentCell); } else { isDrag = false; } EditCells(currentCell); previousCell = currentCell; isDrag = true; }
¿Cómo verificamos el arrastrar y soltar? Comprobando si la celda actual es vecina de la anterior. Verificamos esto al eludir a sus vecinos en un ciclo. Si encontramos una coincidencia, también reconocemos inmediatamente la dirección del arrastre. void ValidateDrag (HexCell currentCell) { for ( dragDirection = HexDirection.NE; dragDirection <= HexDirection.NW; dragDirection++ ) { if (previousCell.GetNeighbor(dragDirection) == currentCell) { isDrag = true; return; } } isDrag = false; }
¿Crearemos drags desiguales?, . «» , .
, . .
Cambiar celdas
Ahora que podemos reconocer arrastrar y soltar, podemos definir ríos salientes. También podemos eliminar ríos; para esto, no se requiere soporte de arrastrar y soltar. void EditCell (HexCell cell) { if (cell) { if (applyColor) { cell.Color = activeColor; } if (applyElevation) { cell.Elevation = activeElevation; } if (riverMode == OptionalToggle.No) { cell.RemoveRiver(); } else if (isDrag && riverMode == OptionalToggle.Yes) { previousCell.SetOutgoingRiver(dragDirection); } } }
Este código dibujará el río desde la celda anterior a la actual. Pero ignora el tamaño del pincel. Esto es bastante lógico, pero dibujemos los ríos para todas las celdas cerradas por el pincel. Esto se puede hacer realizando operaciones en la celda editada. En nuestro caso, debemos asegurarnos de que realmente exista otra célula. else if (isDrag && riverMode == OptionalToggle.Yes) { HexCell otherCell = cell.GetNeighbor(dragDirection.Opposite()); if (otherCell) { otherCell.SetOutgoingRiver(dragDirection); } }
Ahora podemos editar los ríos, pero aún no los vemos. Podemos verificar que esto funciona examinando las celdas modificadas en el inspector de depuración.Una celda con un río en el inspector de depuración.¿Qué es un inspector de depuración?. . , .
paquete de la unidadCauces entre celdas
Al triangular un río, debemos considerar dos partes: la ubicación del lecho del río y el agua que fluye a través de él. Primero, crearemos un canal y dejaremos el agua para más tarde.La parte más simple del río es donde fluye en uniones entre celdas. Mientras estamos triangulando esta área con una tira de tres quad. Podemos agregarle un lecho de río bajando el quad central y agregando dos paredes de canal.Agregar un río a una tira de costilla.Para esto, en el caso del río, se requerirán dos quads adicionales y se creará un canal con dos paredes verticales. Un enfoque alternativo es usar cuatro quad. Luego bajamos el pico medio para crear una cama con paredes inclinadas.Siempre cuatro quad.El uso constante del mismo número de cuadrángulos es conveniente, así que elija esta opción.Agregar bordes de borde
La transición de tres a cuatro por borde requiere la creación de un vértice adicional del borde. Reescribimos EdgeVertices
renombrando primero v4
a v5
, y luego renombrando v3
a v4
. Las acciones en este orden aseguran que todo el código continúe haciendo referencia a los vértices correctos. Use la opción de cambio de nombre o refactorización de su editor para que los cambios se apliquen en todas partes. De lo contrario, tendrá que inspeccionar manualmente todo el código y realizar cambios. public Vector3 v1, v2, v4, v5;
Después de renombrar todo, agregue uno nuevo v3
. public Vector3 v1, v2, v3, v4, v5;
Agregue un nuevo vértice al constructor. Se encuentra en el medio entre los picos de las esquinas. Además, otros vértices ahora deberían estar en ½ y ¾, y no en & frac13; y & frac23;. public EdgeVertices (Vector3 corner1, Vector3 corner2) { v1 = corner1; v2 = Vector3.Lerp(corner1, corner2, 0.25f); v3 = Vector3.Lerp(corner1, corner2, 0.5f); v4 = Vector3.Lerp(corner1, corner2, 0.75f); v5 = corner2; }
Agregar v3
y adentro TerraceLerp
. public static EdgeVertices TerraceLerp ( EdgeVertices a, EdgeVertices b, int step) { EdgeVertices result; result.v1 = HexMetrics.TerraceLerp(a.v1, b.v1, step); result.v2 = HexMetrics.TerraceLerp(a.v2, b.v2, step); result.v3 = HexMetrics.TerraceLerp(a.v3, b.v3, step); result.v4 = HexMetrics.TerraceLerp(a.v4, b.v4, step); result.v5 = HexMetrics.TerraceLerp(a.v5, b.v5, step); return result; }
Ahora HexMesh
debo incluir un vértice adicional en los abanicos de los triángulos de la costilla. void TriangulateEdgeFan (Vector3 center, EdgeVertices edge, Color color) { AddTriangle(center, edge.v1, edge.v2); AddTriangleColor(color); AddTriangle(center, edge.v2, edge.v3); AddTriangleColor(color); AddTriangle(center, edge.v3, edge.v4); AddTriangleColor(color); AddTriangle(center, edge.v4, edge.v5); AddTriangleColor(color); }
Y también en sus franjas de cuadrángulos. void TriangulateEdgeStrip ( EdgeVertices e1, Color c1, EdgeVertices e2, Color c2 ) { AddQuad(e1.v1, e1.v2, e2.v1, e2.v2); AddQuadColor(c1, c2); AddQuad(e1.v2, e1.v3, e2.v2, e2.v3); AddQuadColor(c1, c2); AddQuad(e1.v3, e1.v4, e2.v3, e2.v4); AddQuadColor(c1, c2); AddQuad(e1.v4, e1.v5, e2.v4, e2.v5); AddQuadColor(c1, c2); }
Comparación de cuatro y cinco vértices por borde.La altura del lecho del río
Creamos el canal bajando la parte superior inferior de la costilla. Determina la posición vertical del lecho del río. Aunque la posición vertical exacta de cada celda está distorsionada, debemos mantener la misma altura del lecho del río en celdas con la misma altura. Gracias a esta agua, no tiene que fluir corriente arriba. Además, la cama debe ser lo suficientemente baja como para permanecer debajo, incluso en el caso de las celdas verticales más desviadas, y al mismo tiempo dejar suficiente espacio para el agua.Vamos a establecer este desplazamiento HexMetrics
y expresarlo como altura. Las compensaciones de un nivel serán suficientes. public const float streamBedElevationOffset = -1f;
Podemos usar esta métrica para agregar propiedades HexCell
para obtener la posición vertical del lecho del río celular. public float StreamBedY { get { return (elevation + HexMetrics.streamBedElevationOffset) * HexMetrics.elevationStep; } }
Creando un canal
Cuando HexMesh
una de las seis partes triangulares de una celda se triangula, podemos determinar si un río fluye a lo largo de su borde. Si es así, entonces podemos bajar el pico medio de la costilla a la altura del lecho del río. void Triangulate (HexDirection direction, HexCell cell) { Vector3 center = cell.Position; EdgeVertices e = new EdgeVertices( center + HexMetrics.GetFirstSolidCorner(direction), center + HexMetrics.GetSecondSolidCorner(direction) ); if (cell.HasRiverThroughEdge(direction)) { e.v3.y = cell.StreamBedY; } TriangulateEdgeFan(center, e, cell.Color); if (direction <= HexDirection.SE) { TriangulateConnection(direction, cell, e); } }
Cambia el vértice medio de la costilla.Podemos ver cómo aparecen los primeros signos del río, pero surgen agujeros en el relieve. Para cerrarlos, necesitamos cambiar otro borde y luego triangular la conexión. void TriangulateConnection ( HexDirection direction, HexCell cell, EdgeVertices e1 ) { HexCell neighbor = cell.GetNeighbor(direction); if (neighbor == null) { return; } Vector3 bridge = HexMetrics.GetBridge(direction); bridge.y = neighbor.Position.y - cell.Position.y; EdgeVertices e2 = new EdgeVertices( e1.v1 + bridge, e1.v5 + bridge ); if (cell.HasRiverThroughEdge(direction)) { e2.v3.y = neighbor.StreamBedY; } … }
Canales completos de costillas.paquete de la unidadCauces que pasan por una celda
Ahora tenemos los cauces correctos entre las celdas. Pero cuando el río fluye a través de la celda, los canales siempre terminan en su centro. Para resolver este problema tendrá que funcionar. Comencemos con el caso cuando un río fluye a través de una celda directamente, de un borde al otro.Si no hay río, entonces cada parte de la celda puede ser un simple abanico de triángulos. Pero cuando el río fluye directamente, es necesario insertar un canal. De hecho, necesitamos estirar el vértice central en una línea, convirtiendo los dos triángulos del medio en cuadrángulos. Entonces el abanico de triángulos se convierte en un trapecio.Insertamos el canal en el triángulo.Dichos canales serán mucho más largos que los que pasan a través de la conexión de las células. Esto se hace evidente cuando las posiciones de los vértices están distorsionadas. Por lo tanto, dividamos el trapecio en dos segmentos insertando otro conjunto de bordes de vértice en el medio entre el centro y el borde.Triangulación de canales.Como la triangulación con un río será muy diferente de la triangulación sin un río, creemos un método separado para ello. Si tenemos un río, entonces usamos este método, de lo contrario dejaremos un abanico de triángulos. void Triangulate (HexDirection direction, HexCell cell) { Vector3 center = cell.Position; EdgeVertices e = new EdgeVertices( center + HexMetrics.GetFirstSolidCorner(direction), center + HexMetrics.GetSecondSolidCorner(direction) ); if (cell.HasRiver) { if (cell.HasRiverThroughEdge(direction)) { e.v3.y = cell.StreamBedY; TriangulateWithRiver(direction, cell, center, e); } } else { TriangulateEdgeFan(center, e, cell.Color); } if (direction <= HexDirection.SE) { TriangulateConnection(direction, cell, e); } } void TriangulateWithRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { }
Agujeros en los que debería haber ríos.Para ver mejor lo que sucede, desactive temporalmente la distorsión celular. public const float cellPerturbStrength = 0f;
Picos sin distorsión.Triangulación directamente a través de la célula.
Para crear un canal directamente a través de parte de la celda, necesitamos estirar el centro en una línea. Esta línea debe tener el mismo ancho que el canal. Podemos encontrar el vértice izquierdo moviendo ¼ de la distancia desde el centro hasta la primera esquina de la parte anterior. void TriangulateWithRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { Vector3 centerL = center + HexMetrics.GetFirstSolidCorner(direction.Previous()) * 0.25f; }
Del mismo modo para el vértice derecho. En este caso, necesitamos la segunda esquina de la siguiente parte. Vector3 centerL = center + HexMetrics.GetFirstSolidCorner(direction.Previous()) * 0.25f; Vector3 centerR = center + HexMetrics.GetSecondSolidCorner(direction.Next()) * 0.25f;
La línea media se puede encontrar creando bordes de vértice entre el centro y el borde. EdgeVertices m = new EdgeVertices( Vector3.Lerp(centerL, e.v1, 0.5f), Vector3.Lerp(centerR, e.v5, 0.5f) );
A continuación, cambie el vértice medio de la costilla media, así como el centro, porque se convertirán en los puntos más bajos del canal. m.v3.y = center.y = e.v3.y;
Ahora podemos usar TriangulateEdgeStrip
para llenar el espacio entre la línea media y la línea de borde. TriangulateEdgeStrip(m, cell.Color, e, cell.Color);
Canales comprimidosDesafortunadamente, los canales se ven comprimidos. Esto sucede porque los vértices medios de la costilla están demasiado cerca uno del otro. ¿Por qué sucedió esto?Si suponemos que la longitud del borde exterior es 1, entonces la longitud de la línea central será ½. Dado que el borde medio está en el medio entre ellos, su longitud debe ser igual a ¾.El ancho del canal es ½ y debe permanecer constante. Como la longitud del borde medio es ¾, solo queda ¼, según & frac18; a ambos lados del canal.Longitudes relativas.Como la longitud del borde medio es ¾, entonces & frac18; se vuelve relativo a la longitud de la costilla media igual a & frac16;. Esto significa que sus vértices segundo y cuarto deben interpolarse con sextos, no con cuartos.Podemos proporcionar soporte para dicha interpolación alternativa agregando a EdgeVertices
otro constructor. En lugar de interpolaciones fijas para v2
y v4
usemos un parámetro. public EdgeVertices (Vector3 corner1, Vector3 corner2, float outerStep) { v1 = corner1; v2 = Vector3.Lerp(corner1, corner2, outerStep); v3 = Vector3.Lerp(corner1, corner2, 0.5f); v4 = Vector3.Lerp(corner1, corner2, 1f - outerStep); v5 = corner2; }
Ahora podemos usarlo con & frac16; c HexMesh.TriangulateWithRiver
. EdgeVertices m = new EdgeVertices( Vector3.Lerp(centerL, e.v1, 0.5f), Vector3.Lerp(centerR, e.v5, 0.5f), 1f / 6f );
Canales directosUna vez hecho el canal recto, podemos ir a la segunda parte del trapecio. En este caso, no podemos usar la tira elástica, por lo que debemos hacerlo manualmente. Primero creemos triángulos en los lados. AddTriangle(centerL, m.v1, m.v2); AddTriangleColor(cell.Color); AddTriangle(centerR, m.v4, m.v5); AddTriangleColor(cell.Color);
Triángulos lateralesSe ve bien, así que llenemos el espacio restante con dos cuadrángulos, creando la última parte del canal. AddTriangle(centerL, m.v1, m.v2); AddTriangleColor(cell.Color); AddQuad(centerL, center, m.v2, m.v3); AddQuadColor(cell.Color); AddQuad(center, centerR, m.v3, m.v4); AddQuadColor(cell.Color); AddTriangle(centerR, m.v4, m.v5); AddTriangleColor(cell.Color);
De hecho, no tenemos una alternativa que AddQuadColor
requiera solo un parámetro. Si bien no lo necesitamos. Así que vamos a crearlo. void AddQuadColor (Color color) { colors.Add(color); colors.Add(color); colors.Add(color); colors.Add(color); }
Canales rectos completados.Inicio y fin de triangulación
La triangulación de una parte que solo tiene el principio o el final de un río es bastante diferente y, por lo tanto, requiere su propio método. Por lo tanto, revisaremos esto Triangulate
y llamaremos al método apropiado. if (cell.HasRiver) { if (cell.HasRiverThroughEdge(direction)) { e.v3.y = cell.StreamBedY; if (cell.HasRiverBeginOrEnd) { TriangulateWithRiverBeginOrEnd(direction, cell, center, e); } else { TriangulateWithRiver(direction, cell, center, e); } } }
En este caso, queremos completar el canal en el centro, pero aún utilizamos dos etapas para esto. Por lo tanto, crearemos nuevamente el borde medio entre el centro o el borde. Como queremos completar el canal, estamos muy contentos de que se comprima. void TriangulateWithRiverBeginOrEnd ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { EdgeVertices m = new EdgeVertices( Vector3.Lerp(center, e.v1, 0.5f), Vector3.Lerp(center, e.v5, 0.5f) ); }
Para que el canal no sea poco profundo demasiado rápido, asignaremos la altura del lecho del río al pico medio. Pero el centro no necesita ser cambiado. m.v3.y = e.v3.y;
Podemos triangular con una tira de costilla y un abanico. TriangulateEdgeStrip(m, cell.Color, e, cell.Color); TriangulateEdgeFan(center, m, cell.Color);
Puntos de inicio y fin.Giros de un paso
Luego, considere las curvas cerradas que zigzaguean entre las celdas adyacentes. Nosotros también los manejaremos TriangulateWithRiver
. Por lo tanto, necesitamos determinar con qué tipo de río estamos trabajando.Río ZigzagSi la celda tiene un río que fluye en la dirección opuesta, así como en la dirección con la que estamos trabajando, entonces este debería ser un río recto. En este caso, podemos guardar la línea central que ya hemos calculado. De lo contrario, vuelve a un punto, doblando la línea central. Vector3 centerL, centerR; if (cell.HasRiverThroughEdge(direction.Opposite())) { centerL = center + HexMetrics.GetFirstSolidCorner(direction.Previous()) * 0.25f; centerR = center + HexMetrics.GetSecondSolidCorner(direction.Next()) * 0.25f; } else { centerL = centerR = center; }
Zigzags rizados.Podemos reconocer giros bruscos comprobando si la celda tiene un río que pasa por la parte siguiente o anterior de la celda. Si lo hay, entonces debemos alinear la línea central con el borde entre esta y la parte vecina. Podemos hacer esto colocando el lado correspondiente de la línea en el medio entre el centro y el ángulo común. El otro lado de la línea se convierte en el centro. if (cell.HasRiverThroughEdge(direction.Opposite())) { centerL = center + HexMetrics.GetFirstSolidCorner(direction.Previous()) * 0.25f; centerR = center + HexMetrics.GetSecondSolidCorner(direction.Next()) * 0.25f; } else if (cell.HasRiverThroughEdge(direction.Next())) { centerL = center; centerR = Vector3.Lerp(center, e.v5, 0.5f); } else if (cell.HasRiverThroughEdge(direction.Previous())) { centerL = Vector3.Lerp(center, e.v1, 0.5f); centerR = center; } else { centerL = centerR = center; }
Habiendo decidido dónde están los puntos izquierdo y derecho, podemos determinar el centro resultante promediando. if (cell.HasRiverThroughEdge(direction.Opposite())) { … } center = Vector3.Lerp(centerL, centerR, 0.5f);
Costilla central desplazada.Aunque el canal tiene el mismo ancho en ambos lados, se ve bastante comprimido. Esto es causado al girar la línea central 60 °. Puede suavizar este efecto aumentando ligeramente el ancho de la línea central. En lugar de interpolar con ½, usamos & frac23;. else if (cell.HasRiverThroughEdge(direction.Next())) { centerL = center; centerR = Vector3.Lerp(center, e.v5, 2f / 3f); } else if (cell.HasRiverThroughEdge(direction.Previous())) { centerL = Vector3.Lerp(center, e.v1, 2f / 3f); centerR = center; }
Zigzag sin compresión.Giros de dos etapas.
Los casos restantes son entre zigzags y ríos rectos. Estas son curvas de dos etapas que crean ríos suavemente curvados.El río serpenteante.Para distinguir entre dos orientaciones posibles, necesitamos usar direction.Next().Next()
. Pero vamos a hacer que sea más conveniente mediante la adición de HexDirection
métodos de extensión Next2
y Previous2
. public static HexDirection Previous2 (this HexDirection direction) { direction -= 2; return direction >= HexDirection.NE ? direction : (direction + 6); } public static HexDirection Next2 (this HexDirection direction) { direction += 2; return direction <= HexDirection.NW ? direction : (direction - 6); }
De vuelta a HexMesh.TriangulateWithRiver
. Ahora podemos reconocer la dirección de nuestro río serpenteante con direction.Next2()
. if (cell.HasRiverThroughEdge(direction.Opposite())) { centerL = center + HexMetrics.GetFirstSolidCorner(direction.Previous()) * 0.25f; centerR = center + HexMetrics.GetSecondSolidCorner(direction.Next()) * 0.25f; } else if (cell.HasRiverThroughEdge(direction.Next())) { centerL = center; centerR = Vector3.Lerp(center, e.v5, 2f / 3f); } else if (cell.HasRiverThroughEdge(direction.Previous())) { centerL = Vector3.Lerp(center, e.v1, 2f / 3f); centerR = center; } else if (cell.HasRiverThroughEdge(direction.Next2())) { centerL = centerR = center; } else { centerL = centerR = center; }
En estos dos últimos casos, necesitamos desplazar la línea central a la parte de la celda que se encuentra en el interior de la curva. Si tuviéramos un vector en el medio de un borde sólido, entonces podríamos usarlo para colocar el punto final. Imaginemos que tenemos un método para esto. else if (cell.HasRiverThroughEdge(direction.Next2())) { centerL = center; centerR = center + HexMetrics.GetSolidEdgeMiddle(direction.Next()) * 0.5f; } else { centerL = center + HexMetrics.GetSolidEdgeMiddle(direction.Previous()) * 0.5f; centerR = center; }
Por supuesto, ahora necesitamos agregar dicho método a HexMetrics
. Solo tiene que promediar dos vectores de ángulos adyacentes y aplicar el coeficiente de integridad. public static Vector3 GetSolidEdgeMiddle (HexDirection direction) { return (corners[(int)direction] + corners[(int)direction + 1]) * (0.5f * solidFactor); }
Curvas ligeramente comprimidas.Nuestras líneas centrales ahora están rotadas correctamente 30 °. Pero no son lo suficientemente largos, por eso los canales están un poco comprimidos. Esto sucede porque el punto medio de la costilla está más cerca del centro que el ángulo de la costilla. Su distancia es igual al radio interno, no al externo. Es decir, estamos trabajando en la escala incorrecta.Ya estamos convirtiendo de radio externo a interno en HexMetrics
. Necesitamos realizar la operación inversa. Así que hagamos disponibles ambos factores de conversión a través de HexMetrics
. public const float outerToInner = 0.866025404f; public const float innerToOuter = 1f / outerToInner; public const float outerRadius = 10f; public const float innerRadius = outerRadius * outerToInner;
Ahora podemos pasar a la escala correcta HexMesh.TriangulateWithRiver
. Los canales seguirán un poco apretados debido a su turno, pero esto es mucho menos pronunciado que en el caso de los zigzags. Por lo tanto, no necesitamos compensar esto. else if (cell.HasRiverThroughEdge(direction.Next2())) { centerL = center; centerR = center + HexMetrics.GetSolidEdgeMiddle(direction.Next()) * (0.5f * HexMetrics.innerToOuter); } else { centerL = center + HexMetrics.GetSolidEdgeMiddle(direction.Previous()) * (0.5f * HexMetrics.innerToOuter); centerR = center; }
Curvas suaves.paquete de la unidadTriangulación en las cercanías de los ríos.
Nuestros ríos están listos. Pero aún no hemos triangulado otras partes de las células que contienen los ríos. Ahora cerraremos estos agujeros.Agujeros cerca de los canales.Si la celda tiene un río, pero no fluye en la dirección actual, Triangulate
llamaremos a un nuevo método. if (cell.HasRiver) { if (cell.HasRiverThroughEdge(direction)) { e.v3.y = cell.StreamBedY; if (cell.HasRiverBeginOrEnd) { TriangulateWithRiverBeginOrEnd(direction, cell, center, e); } else { TriangulateWithRiver(direction, cell, center, e); } } else { TriangulateAdjacentToRiver(direction, cell, center, e); } } else { TriangulateEdgeFan(center, e, cell.Color); }
En este método, llenamos el triángulo de la celda con una tira y un abanico. Solo un abanico no será suficiente para nosotros, porque los picos deben corresponder al borde medio de las partes que contienen el río. void TriangulateAdjacentToRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { EdgeVertices m = new EdgeVertices( Vector3.Lerp(center, e.v1, 0.5f), Vector3.Lerp(center, e.v5, 0.5f) ); TriangulateEdgeStrip(m, cell.Color, e, cell.Color); TriangulateEdgeFan(center, m, cell.Color); }
Superposición en curvas y ríos rectos.Coincide con el canal
Por supuesto, tenemos que hacer que el centro que utilizamos coincida con la parte central utilizada por las partes del río. Con los zigzags, todo está en orden, y las curvas y los ríos rectos requieren atención. Por lo tanto, necesitamos determinar tanto el tipo de río como su orientación relativa.Comencemos por verificar si estamos dentro de la curva. En este caso, tanto la dirección anterior como la siguiente contienen el río. Si es así, entonces necesitamos mover el centro al borde. if (cell.HasRiverThroughEdge(direction.Next())) { if (cell.HasRiverThroughEdge(direction.Previous())) { center += HexMetrics.GetSolidEdgeMiddle(direction) * (HexMetrics.innerToOuter * 0.5f); } } EdgeVertices m = new EdgeVertices( Vector3.Lerp(center, e.v1, 0.5f), Vector3.Lerp(center, e.v5, 0.5f) );
Se corrigió un caso en el que el río fluía desde ambos lados.Si tenemos un río en una dirección diferente, pero no en la anterior, entonces verificamos si es recto. Si es así, mueva el centro a la primera esquina. if (cell.HasRiverThroughEdge(direction.Next())) { if (cell.HasRiverThroughEdge(direction.Previous())) { center += HexMetrics.GetSolidEdgeMiddle(direction) * (HexMetrics.innerToOuter * 0.5f); } else if ( cell.HasRiverThroughEdge(direction.Previous2()) ) { center += HexMetrics.GetFirstSolidCorner(direction) * 0.25f; } }
Se corrigió la mitad superpuesta con un río recto.Entonces resolvimos el problema con la mitad de las partes adyacentes a los ríos rectos. El último caso: tenemos un río en la dirección anterior, y es recto. En este caso, debe mover el centro a la siguiente esquina. if (cell.HasRiverThroughEdge(direction.Next())) { if (cell.HasRiverThroughEdge(direction.Previous())) { center += HexMetrics.GetSolidEdgeMiddle(direction) * (HexMetrics.innerToOuter * 0.5f); } else if ( cell.HasRiverThroughEdge(direction.Previous2()) ) { center += HexMetrics.GetFirstSolidCorner(direction) * 0.25f; } } else if ( cell.HasRiverThroughEdge(direction.Previous()) && cell.HasRiverThroughEdge(direction.Next2()) ) { center += HexMetrics.GetSecondSolidCorner(direction) * 0.25f; }
No más superposiciones.paquete de la unidadGeneralización HexMesh
Hemos completado la triangulación de canales. Ahora podemos llenarlos con agua. Como el agua es diferente de la tierra, necesitaremos usar una malla diferente con diferentes datos de vértice y material diferente. Sería bastante conveniente si pudiéramos usar HexMesh
tanto sushi como agua. Así que generalicemos HexMesh
convirtiéndolo en una clase que se ocupe de estas mallas, independientemente de para qué se use. Vamos a pasar la tarea de triangular sus celdas HexGridChunk
.Moviendo el Método Perturb
Como el método es Perturb
bastante generalizado y se usará en diferentes lugares, vamos a moverlo a HexMetrics
. Primero, cámbiele el nombre a HexMetrics.Perturb
. Este es un nombre de método incorrecto, pero refactoriza todo el código para su uso correcto. Si su editor de código tiene una funcionalidad especial para mover métodos, úselo.Al mover el método hacia adentro HexMetrics
, hágalo general y estático, y luego corrija su nombre. public static Vector3 Perturb (Vector3 position) { Vector4 sample = SampleNoise(position); position.x += (sample.x * 2f - 1f) * cellPerturbStrength; position.z += (sample.z * 2f - 1f) * cellPerturbStrength; return position; }
Métodos de triangulación en movimiento
En HexGridChunk
reemplazar la variable hexMesh
con una variable común terrain
. public HexMesh terrain;
A continuación, refactorizamos todos los métodos Add…
de HexMesh
c terrain.Add…
. Luego mueva todos los métodos Triangulate…
a HexGridChunk
. A continuación, puede corregir los nombres de los métodos Add…
en HexMesh
y hacerlos común. Como resultado, se encontrarán todos los métodos de triangulación complejos HexGridChunk
y se mantendrán métodos simples para agregar datos a la malla HexMesh
.Aún no hemos terminado. Ahora HexGridChunk.LateUpdate
debería llamar a su propio método Triangulate
. Además, ya no debería pasar celdas como argumento. Por lo tanto, Triangulate
puede perder su parámetro. Y debe delegar la limpieza y la aplicación de los datos de malla HexMesh
. void LateUpdate () { Triangulate();
Añadir los métodos necesarios Clear
y Apply
en HexMesh
. public void Clear () { hexMesh.Clear(); vertices.Clear(); colors.Clear(); triangles.Clear(); } public void Apply () { hexMesh.SetVertices(vertices); hexMesh.SetColors(colors); hexMesh.SetTriangles(triangles, 0); hexMesh.RecalculateNormals(); meshCollider.sharedMesh = hexMesh; }
¿Qué pasa con SetVertices, SetColors y SetTriangles?Mesh
. . , .
SetTriangles
integer, . , .
Finalmente, adjunte manualmente el elemento secundario de la malla al fragmento prefabricado. Ya no podemos hacer esto automáticamente porque pronto agregaremos un segundo hijo a la malla. Cámbiele el nombre a Terreno para indicar su propósito.Asignar un alivio.Cambiar el nombre de un niño prefabricado no funciona?. , . , Apply , . .
Crear grupos de listas
Aunque hemos movido bastante código, nuestro mapa aún debería funcionar igual que antes. Agregar otra malla al fragmento no cambiará esto. Pero si hacemos esto con el presente HexMesh
, pueden surgir errores.El problema es que asumimos que solo trabajaríamos con una malla a la vez. Esto nos permitió usar listas estáticas para almacenar datos temporales de malla. Pero después de agregar agua, trabajaremos simultáneamente con dos mallas, por lo que ya no podremos usar listas estáticas.Sin embargo, no volveremos a los conjuntos de listas para cada instancia HexMesh
. En su lugar, usamos un grupo de listas estáticas. Por defecto, esta agrupación no existe, así que comencemos creando una clase común de agrupación de listas. public static class ListPool<T> { }
¿Cómo funciona ListPool <T>?, List<int>
. <T>
ListPool
, , . , T
( template).
Para almacenar una colección de listas en un grupo, podemos usar la pila. Por lo general, no uso listas porque Unity no las serializa, pero en este caso no importa. using System.Collections.Generic; public static class ListPool<T> { static Stack<List<T>> stack = new Stack<List<T>>(); }
¿Qué significa stack <list <t>>?. , . .
Agregue un método estático común para obtener la lista del grupo. Si la pila no está vacía, extraeremos la lista superior y devolveremos esta. De lo contrario, crearemos una nueva lista en su lugar. public static List<T> Get () { if (stack.Count > 0) { return stack.Pop(); } return new List<T>(); }
Para reutilizar las listas, debe agregarlas al grupo después de que termine de trabajar con ellas. ListPool
borrará la lista y la empujará a la pila. public static void Add (List<T> list) { list.Clear(); stack.Push(list); }
Ahora podemos usar las piscinas en HexMesh
. Reemplace las listas estáticas con enlaces privados no estáticos. Vamos a marcarlos NonSerialized
para que Unity no los conserve durante la compilación. O escriba System.NonSerialized
o agregue using System;
al comienzo del guión. [NonSerialized] List<Vector3> vertices; [NonSerialized] List<Color> colors; [NonSerialized] List<int> triangles;
Dado que la malla se limpia justo antes de agregarle nuevos datos, es aquí donde debe obtener listas de los grupos. public void Clear () { hexMesh.Clear(); vertices = ListPool<Vector3>.Get(); colors = ListPool<Color>.Get(); triangles = ListPool<int>.Get(); }
Después de aplicar estas mallas, ya no las necesitamos, así que aquí podemos agregarlas a las piscinas. public void Apply () { hexMesh.SetVertices(vertices); ListPool<Vector3>.Add(vertices); hexMesh.SetColors(colors); ListPool<Color>.Add(colors); hexMesh.SetTriangles(triangles, 0); ListPool<int>.Add(triangles); hexMesh.RecalculateNormals(); meshCollider.sharedMesh = hexMesh; }
Así que implementamos el uso múltiple de listas, independientemente de cuántas mallas llenemos al mismo tiempo.Colisionador opcional
Aunque nuestro terreno necesita un colisionador, no es realmente necesario para los ríos. Los rayos simplemente pasarán por el agua y se cruzarán con el canal debajo. Hagámoslo para que podamos configurar la presencia de un colisionador para HexMesh
. Nos damos cuenta de esto agregando un campo común bool useCollider
. Para el terreno, lo activamos. public bool useCollider;
Usando un colisionador de malla.Necesitamos crear y asignar el colisionador solo cuando está activado. void Awake () { GetComponent<MeshFilter>().mesh = hexMesh = new Mesh(); if (useCollider) { meshCollider = gameObject.AddComponent<MeshCollider>(); } hexMesh.name = "Hex Mesh"; } public void Apply () { … if (useCollider) { meshCollider.sharedMesh = hexMesh; } … }
Colores opcionales
Los colores de vértice también pueden ser opcionales. Los necesitamos para demostrar varios tipos de alivio, pero el agua no cambia de color. Podemos hacerlos opcionales al igual que hicimos que el colisionador sea opcional. public bool useCollider, useColors; public void Clear () { hexMesh.Clear(); vertices = ListPool<Vector3>.Get(); if (useColors) { colors = ListPool<Color>.Get(); } triangles = ListPool<int>.Get(); } public void Apply () { hexMesh.SetVertices(vertices); ListPool<Vector3>.Add(vertices); if (useColors) { hexMesh.SetColors(colors); ListPool<Color>.Add(colors); } … }
Por supuesto, el terreno debe usar los colores de los vértices, así que actívalos.Uso de colores.UV opcional
Mientras hacemos esto, también podemos agregar soporte para coordenadas UV opcionales. Aunque el alivio no los usa, los necesitaremos para el agua. public bool useCollider, useColors, useUVCoordinates; [NonSerialized] List<Vector2> uvs; public void Clear () { hexMesh.Clear(); vertices = ListPool<Vector3>.Get(); if (useColors) { colors = ListPool<Color>.Get(); } if (useUVCoordinates) { uvs = ListPool<Vector2>.Get(); } triangles = ListPool<int>.Get(); } public void Apply () { hexMesh.SetVertices(vertices); ListPool<Vector3>.Add(vertices); if (useColors) { hexMesh.SetColors(colors); ListPool<Color>.Add(colors); } if (useUVCoordinates) { hexMesh.SetUVs(0, uvs); ListPool<Vector2>.Add(uvs); } … }
No utilizamos coordenadas UV.Para usar esta función, cree métodos para agregar coordenadas UV a triángulos y cuadrángulos. public void AddTriangleUV (Vector2 uv1, Vector2 uv2, Vector3 uv3) { uvs.Add(uv1); uvs.Add(uv2); uvs.Add(uv3); } public void AddQuadUV (Vector2 uv1, Vector2 uv2, Vector3 uv3, Vector3 uv4) { uvs.Add(uv1); uvs.Add(uv2); uvs.Add(uv3); uvs.Add(uv4); }
Agreguemos un método adicional AddQuadUV
para agregar convenientemente un área rectangular de UV. Este es el caso estándar cuando el quad y su textura son iguales, lo usaremos para el agua del río. public void AddQuadUV (float uMin, float uMax, float vMin, float vMax) { uvs.Add(new Vector2(uMin, vMin)); uvs.Add(new Vector2(uMax, vMin)); uvs.Add(new Vector2(uMin, vMax)); uvs.Add(new Vector2(uMax, vMax)); }
paquete de la unidadRíos actuales
¡Finalmente es hora de crear agua! Haremos esto con un quad, que indicará la superficie del agua. Y como trabajamos con ríos, el agua debe fluir. Para hacer esto, usamos coordenadas UV que indican la orientación del río. Para visualizar esto, necesitamos un nuevo sombreador. Por lo tanto, cree un nuevo sombreador estándar y llámelo River . Cámbielo para que las coordenadas UV se registren en los canales de albedo verde y rojo. Shader "Custom/River" { … void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color; o.Albedo = c.rgb * IN.color; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = ca; o.Albedo.rg = IN.uv_MainTex; } ENDCG } FallBack "Diffuse" }
Añadir al HexGridChunk
campo general HexMesh rivers
. Lo limpiamos y lo aplicamos de la misma manera que en el caso del alivio. public HexMesh terrain, rivers; public void Triangulate () { terrain.Clear(); rivers.Clear(); for (int i = 0; i < cells.Length; i++) { Triangulate(cells[i]); } terrain.Apply(); rivers.Apply(); }
¿Tendremos llamadas adicionales, incluso si no tenemos ríos?Unity , . , - .
Cambie el prefab (a través de la instancia), duplicando su objeto de terreno, renombrándolo a Rivers y conectándolo.Fragmento prefabricado con ríos.Crea el material River usando nuestro nuevo sombreador y haz que el objeto Rivers lo use . También configuramos el componente de malla hexagonal del objeto para que use coordenadas UV, pero no use colores de vértice ni el colisionador.Subobjeto Ríos.Agua triangular
Antes de poder triangular el agua, necesitamos determinar el nivel de su superficie. Hagamos un cambio de altura HexMetrics
, como hicimos con el lecho del río. Como la distorsión vertical de la celda es igual a la mitad del desplazamiento de altura, usémosla para desplazar la superficie del río. Por lo tanto, garantizamos que el agua nunca estará por encima de la topografía de la celda. public const float riverSurfaceElevationOffset = -0.5f;
¿Por qué no hacerlo un poco más bajo?, . , .
Agregue una HexCell
propiedad para obtener la posición vertical de la superficie de su río. public float RiverSurfaceY { get { return (elevation + HexMetrics.riverSurfaceElevationOffset) * HexMetrics.elevationStep; } }
¡Ahora podemos trabajar HexGridChunk
! Como crearemos muchos cuadrángulos de ríos, agreguemos un método separado para esto. Vamos a darle cuatro vértices y una altura como parámetros. Esto nos permitirá establecer convenientemente la posición vertical de los cuatro vértices simultáneamente antes de agregar quad. void TriangulateRiverQuad ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y ) { v1.y = v2.y = v3.y = v4.y = y; rivers.AddQuad(v1, v2, v3, v4); }
Agregaremos aquí las coordenadas UV del cuadrilátero. Simplemente vaya de izquierda a derecha y de abajo hacia arriba. rivers.AddQuad(v1, v2, v3, v4); rivers.AddQuadUV(0f, 1f, 0f, 1f);
TriangulateWithRiver
- Este es el primer método al que agregaremos los cuadrángulos de los ríos. El primer quad está entre el centro y el medio. El segundo es entre el medio y la costilla. Solo usamos los vértices que ya tenemos. Como estos picos se subestimarán, el agua como resultado estará parcialmente debajo de las paredes inclinadas del canal. Por lo tanto, no necesitamos preocuparnos por la posición exacta del borde del agua. void TriangulateWithRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … TriangulateRiverQuad(centerL, centerR, m.v2, m.v4, cell.RiverSurfaceY); TriangulateRiverQuad(m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY); }
Los primeros signos de agua.¿Por qué cambia el ancho del agua?, , — . . .
Moviéndose con la corriente
Actualmente, las coordenadas UV no son consistentes con la dirección del río. Necesitamos mantener la coherencia aquí. Suponga que la coordenada U es 0 en el lado izquierdo del río y 1 en el derecho, cuando se mira aguas abajo. Y la coordenada V debe variar de 0 a 1 en la dirección del río.Usando esta especificación, los UV serán correctos cuando el río saliente sea triangulado, pero resultarán incorrectos y deberán cambiarse cuando el río entrante sea triangulado. Para simplificar el trabajo, agregue al TriangulateRiverQuad
parámetro bool reversed
. Úselo para voltear los rayos UV si es necesario. void TriangulateRiverQuad ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y, bool reversed ) { v1.y = v2.y = v3.y = v4.y = y; rivers.AddQuad(v1, v2, v3, v4); if (reversed) { rivers.AddQuadUV(1f, 0f, 1f, 0f); } else { rivers.AddQuadUV(0f, 1f, 0f, 1f); } }
En TriangulateWithRiver
sabemos que necesitamos invertir la dirección cuando se trata de un río entrante. bool reversed = cell.IncomingRiver == direction; TriangulateRiverQuad( centerL, centerR, m.v2, m.v4, cell.RiverSurfaceY, reversed ); TriangulateRiverQuad( m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY, reversed );
La dirección acordada de los ríos.El principio y el fin del río.
En el interior, TriangulateWithRiverBeginOrEnd
solo necesitamos verificar si tenemos un río entrante para determinar la dirección del flujo. Luego podemos insertar otro río cuádruple entre el medio y la costilla. void TriangulateWithRiverBeginOrEnd ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … bool reversed = cell.HasIncomingRiver; TriangulateRiverQuad( m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY, reversed ); }
La parte entre el centro y el medio es un triángulo, por lo que no podemos usarlo TriangulateRiverQuad
. La única diferencia significativa aquí es que el pico central está en el medio del río. Por lo tanto, su coordenada U siempre es igual a ½. center.y = m.v2.y = m.v4.y = cell.RiverSurfaceY; rivers.AddTriangle(center, m.v2, m.v4); if (reversed) { rivers.AddTriangleUV( new Vector2(0.5f, 1f), new Vector2(1f, 0f), new Vector2(0f, 0f) ); } else { rivers.AddTriangleUV( new Vector2(0.5f, 0f), new Vector2(0f, 1f), new Vector2(1f, 1f) ); }
Agua al principio y al final.¿Faltan porciones de agua en los extremos?, quad , . . .
, . , . .
Flujo entre celdas
Al agregar agua entre las celdas, debemos tener cuidado con la diferencia de altura. Para que el agua pueda fluir por laderas y acantilados, TriangulateRiverQuad
debe soportar dos parámetros de altura. Así que agreguemos una segunda. void TriangulateRiverQuad ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y1, float y2, bool reversed ) { v1.y = v2.y = y1; v3.y = v4.y = y2; rivers.AddQuad(v1, v2, v3, v4); if (reversed) { rivers.AddQuadUV(1f, 0f, 1f, 0f); } else { rivers.AddQuadUV(0f, 1f, 0f, 1f); } }
Además, para mayor comodidad, agreguemos una opción que recibirá una altura. Simplemente llamará a otro método. void TriangulateRiverQuad ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y, bool reversed ) { TriangulateRiverQuad(v1, v2, v3, v4, y, y, reversed); }
Ahora podemos agregar quad river y adentro TriangulateConnection
. Al estar entre las celdas, no podemos saber de inmediato con qué tipo de río estamos tratando. Para determinar si es necesario un giro, debemos verificar si tenemos un río entrante y si se está moviendo en nuestra dirección. if (cell.HasRiverThroughEdge(direction)) { e2.v3.y = neighbor.StreamBedY; TriangulateRiverQuad( e1.v2, e1.v4, e2.v2, e2.v4, cell.RiverSurfaceY, neighbor.RiverSurfaceY, cell.HasIncomingRiver && cell.IncomingRiver == direction ); }
El río completado.Estiramiento de coordenadas V
Hasta ahora, en cada segmento del río, tenemos coordenadas V que van de 0 a 1. Es decir, solo hay cuatro de ellas en la celda. Cinco si también agregamos conexiones entre celdas. Cualquier cosa que usemos para texturizar el río, debe repetirse la misma cantidad de veces.Podemos reducir el número de repeticiones estirando las coordenadas V para que pasen de 0 a 1 en toda la celda más una conexión. Esto se puede hacer aumentando la coordenada V en cada segmento en 0.2. Si ponemos 0.4 en el centro, entonces en el medio se convertirá en 0.6, y en el borde llegará a 0.8. Luego, en la conexión celular, el valor será 1.Si el río fluye en la dirección opuesta, todavía podemos poner 0.4 en el centro, pero en el medio se convierte en 0.2, y en el borde - 0. Si continuamos esto hasta que la celda se una, obtenemos -0.2 como resultado. Esto es normal porque es similar a 0,8 para una textura con modo de filtrado de repetición, del mismo modo que 0 es equivalente a 1.Cambio de coordenadas V.Para crear soporte para esto, necesitamos agregar TriangulateRiverQuad
un parámetro más. void TriangulateRiverQuad ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y, float v, bool reversed ) { TriangulateRiverQuad(v1, v2, v3, v4, y, y, v, reversed); } void TriangulateRiverQuad ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y1, float y2, float v, bool reversed ) { … }
Cuando la dirección no se invierte, simplemente usamos la coordenada transmitida en la parte inferior del cuadrángulo y agregamos 0.2 en la parte superior. else { rivers.AddQuadUV(0f, 1f, v, v + 0.2f); }
Podemos trabajar con una dirección invertida restando la coordenada de 0.8 y 0.6. if (reversed) { rivers.AddQuadUV(1f, 0f, 0.8f - v, 0.6f - v); }
Ahora debemos transmitir las coordenadas correctas, como si estuviéramos tratando con un río saliente. Vamos a comenzar con TriangulateWithRiver
. TriangulateRiverQuad( centerL, centerR, m.v2, m.v4, cell.RiverSurfaceY, 0.4f, reversed ); TriangulateRiverQuad( m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY, 0.6f, reversed );
Luego TriangulateConnection
cambie de la siguiente manera. TriangulateRiverQuad( e1.v2, e1.v4, e2.v2, e2.v4, cell.RiverSurfaceY, neighbor.RiverSurfaceY, 0.8f, cell.HasIncomingRiver && cell.IncomingRiver == direction );
Por último TriangulateWithRiverBeginOrEnd
. TriangulateRiverQuad( m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY, 0.6f, reversed ); center.y = m.v2.y = m.v4.y = cell.RiverSurfaceY; rivers.AddTriangle(center, m.v2, m.v4); if (reversed) { rivers.AddTriangleUV( new Vector2(0.5f, 0.4f), new Vector2(1f, 0.2f), new Vector2(0f, 0.2f) ); } else { rivers.AddTriangleUV( new Vector2(0.5f, 0.4f), new Vector2(0f, 0.6f), new Vector2(1f, 0.6f) ); }
Coordenadas V estiradas.Para visualizar correctamente el plegado de las coordenadas V, asegúrese de que sigan siendo positivas en el sombreador del río. if (IN.uv_MainTex.y < 0) { IN.uv_MainTex.y += 1; } o.Albedo.rg = IN.uv_MainTex;
Coordenadas contraídas V.unitpackageAnimación del río
Habiendo terminado con las coordenadas UV, podemos pasar a animar los ríos. El sombreador de ríos hará esto para que no tengamos que actualizar constantemente la malla.No crearemos un sombreador de río complejo en este tutorial, pero lo haremos más adelante. Por ahora, crearemos un efecto simple que permita comprender cómo funciona la animación.La animación se crea cambiando las coordenadas V en función del tiempo del juego. Unity le permite obtener su valor utilizando una variable _Time
. Su componente Y contiene el tiempo sin cambios, que usamos. Otros componentes contienen diferentes escalas de tiempo.Eliminaremos el plegado a lo largo de V, porque ya no lo necesitamos. En cambio, restamos el tiempo actual de la coordenada V. Esto desplaza la coordenada hacia abajo, lo que crea la ilusión de la corriente que fluye aguas abajo del río. // if (IN.uv_MainTex.y < 0) { // IN.uv_MainTex.y += 1; // } IN.uv_MainTex.y -= _Time.y; o.Albedo.rg = IN.uv_MainTex;
En un segundo, la coordenada V en todos los puntos será inferior a cero, por lo que ya no veremos la diferencia. Nuevamente, esto es normal cuando se usa el filtrado en el modo de repetición de textura. Pero para ver qué sucede, podemos tomar la parte fraccionaria de la coordenada V. IN.uv_MainTex.y -= _Time.y; IN.uv_MainTex.y = frac(IN.uv_MainTex.y); o.Albedo.rg = IN.uv_MainTex;
Coordenadas animadas V.Uso de ruido
Ahora nuestro río está animado, pero en la dirección y velocidad hay transiciones bruscas. Nuestro patrón UV los hace bastante obvios, pero será más difícil de reconocer si usa un patrón más parecido al agua. Entonces, en lugar de mostrar UV en bruto, muestreemos la textura. Podemos usar nuestra textura de ruido existente. Lo muestreamos y multiplicamos el color del material por el primer canal de ruido. void surf (Input IN, inout SurfaceOutputStandard o) { float2 uv = IN.uv_MainTex; uv.y -= _Time.y; float4 noise = tex2D(_MainTex, uv); fixed4 c = _Color * noise.r; o.Albedo = c.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = ca; }
Asigne la textura de ruido al material del río y asegúrese de que sea blanco.Usando textura de ruido.Como las coordenadas V están muy estiradas, la textura del ruido también se extiende a lo largo del río. Lamentablemente, el curso no es muy bonito. Tratemos de estirarlo de otra manera, reduciendo en gran medida la escala de las coordenadas de U. Un dieciseisavo será suficiente. Esto significa que solo tomaremos muestras de una banda estrecha de textura de ruido. float2 uv = IN.uv_MainTex; uv.x *= 0.0625; uv.y -= _Time.y;
Estirar la coordenada U.También reduzcamos la velocidad a un cuarto por segundo para que la finalización del ciclo de textura tome cuatro segundos. uv.y -= _Time.y * 0.25;
El ruido actual.Mezcla de ruido
Todo ya se ve mucho mejor, pero el patrón siempre sigue siendo el mismo. El agua no se comporta así.Como usamos solo una pequeña banda de ruido, podemos variar el patrón cambiando esta banda a lo largo de la textura. Esto se hace agregando tiempo a la coordenada U. Debemos hacerlo lentamente, de lo contrario el río parecerá fluir de lado. Probemos el coeficiente de 0.005. Esto significa que lleva 200 segundos completar el patrón. uv.x = uv.x * 0.0625 + _Time.y * 0.005;
Ruido en movimiento.Desafortunadamente, esto no se ve muy hermoso. El agua todavía parece estática y el cambio es claramente notable, aunque es muy lento. Podemos ocultar el cambio combinando dos muestras de ruido y desplazándolas en direcciones opuestas. Y si usamos valores ligeramente diferentes para mover la segunda muestra, crearemos una animación ligera del cambio.Como resultado, nunca superponemos el mismo patrón de ruido, usamos un canal diferente para la segunda muestra. float2 uv = IN.uv_MainTex; uv.x = uv.x * 0.0625 + _Time.y * 0.005; uv.y -= _Time.y * 0.25; float4 noise = tex2D(_MainTex, uv); float2 uv2 = IN.uv_MainTex; uv2.x = uv2.x * 0.0625 - _Time.y * 0.0052; uv2.y -= _Time.y * 0.23; float4 noise2 = tex2D(_MainTex, uv2); fixed4 c = _Color * (noise.r * noise2.a);
Una combinación de dos patrones de ruido cambiantes.Agua translúcida
Nuestro patrón se ve bastante dinámico. El siguiente paso es hacerlo translúcido.Primero, asegúrese de que el agua no arroje sombras. Puede deshabilitarlos a través del componente de representación del objeto Rivers en el prefabricado.La proyección de sombras está desactivada.Ahora cambie el sombreador al modo transparente. Para indicar esto, use etiquetas de sombreador. Luego agregue la #pragma surface
palabra clave a la línea alpha
. Mientras estamos aquí, puede eliminar la palabra clave fullforwardshadows
, porque todavía no proyectamos sombras. Tags { "RenderType"="Transparent" "Queue"="Transparent" } LOD 200 CGPROGRAM #pragma surface surf Standard alpha // fullforwardshadows #pragma target 3.0
Ahora cambiaremos la forma en que establecemos el color del río. En lugar de multiplicar el ruido por el color, le agregaremos ruido. Luego usamos la función saturate
para limitar el resultado para que no exceda 1. fixed4 c = saturate(_Color + noise.r * noise2.a);
Esto nos permitirá usar el color del material como color base. El ruido aumentará su brillo y opacidad. Intentemos usar un color azul con una opacidad bastante baja. Como resultado, obtenemos agua azul translúcida con salpicaduras blancas.Agua translúcida coloreada.paquete de la unidadFinalización
Ahora que todo parece estar funcionando, es hora de distorsionar los picos nuevamente. Además de deformar los bordes de las celdas, esto hará que nuestros ríos sean desiguales. public const float cellPerturbStrength = 4f;
Picos distorsionados y distorsionados.Examinemos el terreno en busca de problemas que hayan surgido debido a la distorsión. Parece que son! Echemos un vistazo a las cascadas altas.Agua truncada por acantilados.El agua que cae de una cascada alta desaparece detrás de un acantilado. Cuando esto sucede, es muy notable, por lo que debemos hacer algo al respecto.Mucho menos obvio es que las cascadas pueden ser inclinadas, en lugar de descender directamente hacia abajo. Aunque el agua en realidad no fluye así, no es particularmente notable. Nuestro cerebro lo interpretará de tal manera que nos parezca normal. Así que ignóralo.La forma más fácil de evitar la pérdida de agua es profundizando los cauces de los ríos. Entonces crearemos más espacio entre la superficie del agua y el lecho del río. También hará que las paredes del canal sean más verticales, por lo que no debe ir demasiado profundo. PreguntemosHexMetrics.streamBedElevationOffset
valor -1.75. Esto resolverá la mayor parte de los problemas y la cama no será demasiado profunda. Parte del agua seguirá cortada, pero no todas las cascadas. public const float streamBedElevationOffset = -1.75f;
Canales en profundidad.paquete de la unidadParte 7: caminos
- Añadir soporte vial.
- Triangular el camino.
- Combinamos caminos y ríos.
- Mejora del aspecto de las carreteras.
Los primeros signos de civilización.Celdas con caminos
Al igual que los ríos, los caminos van de celda en celda, a través de los bordes de la celda. La gran diferencia es que no fluye agua en las carreteras, por lo que son bidireccionales. Además, se requieren intersecciones para una red de carreteras funcional, por lo que debemos admitir más de dos carreteras por celda.Si permite que los caminos vayan en las seis direcciones, la celda puede contener de cero a seis caminos. Eso es un total de catorce posibles configuraciones de carreteras. Esto es mucho más que cinco configuraciones posibles de ríos. Para manejar esto, necesitamos usar un enfoque más general que pueda manejar todas las configuraciones.14 posibles configuraciones de carreteras.Seguimiento de carreteras
La forma más sencilla de rastrear carreteras en una celda es usar una matriz de valores booleanos. Agregue el campo privado de la matriz HexCell
y hágalo serializable para que pueda verlo en el inspector. Establezca el tamaño de la matriz a través de la celda prefabricada para que admita seis caminos. [SerializeField] bool[] roads;
Celda prefabricada con seis caminos.Agregue un método para verificar si la celda tiene una ruta en cierta dirección. public bool HasRoadThroughEdge (HexDirection direction) { return roads[(int)direction]; }
También será conveniente saber si hay al menos un camino en la celda, por lo que agregaremos una propiedad para esto. Simplemente recorra la matriz en el bucle y regrese true
tan pronto como encontremos el camino. Si no hay caminos, regrese false
. public bool HasRoads { get { for (int i = 0; i < roads.Length; i++) { if (roads[i]) { return true; } } return false; } }
Eliminación de carreteras
Al igual que con los ríos, agregaremos un método para eliminar todos los caminos de la celda. Esto se puede hacer con un bucle que desconecta cada carretera que estaba habilitada anteriormente. public void RemoveRoads () { for (int i = 0; i < neighbors.Length; i++) { if (roads[i]) { roads[i] = false; } } }
Por supuesto, también debemos desactivar las costosas celdas correspondientes en los vecinos. if (roads[i]) { roads[i] = false; neighbors[i].roads[(int)((HexDirection)i).Opposite()] = false; }
Después de eso, necesitamos actualizar cada una de las celdas. Dado que los caminos son locales a las celdas, necesitamos actualizar solo las celdas sin sus vecinos. if (roads[i]) { roads[i] = false; neighbors[i].roads[(int)((HexDirection)i).Opposite()] = false; neighbors[i].RefreshSelfOnly(); RefreshSelfOnly(); }
Agregar caminos
Agregar carreteras es similar a eliminar carreteras. La única diferencia es que asignamos un valor a Boolean true
, no false
. Podemos crear un método privado que pueda realizar ambas operaciones. Entonces será posible usarlo tanto para agregar como para eliminar el camino. public void AddRoad (HexDirection direction) { if (!roads[(int)direction]) { SetRoad((int)direction, true); } } public void RemoveRoads () { for (int i = 0; i < neighbors.Length; i++) { if (roads[i]) { SetRoad(i, false); } } } void SetRoad (int index, bool state) { roads[index] = state; neighbors[index].roads[(int)((HexDirection)index).Opposite()] = state; neighbors[index].RefreshSelfOnly(); RefreshSelfOnly(); }
No podemos tener un río y una carretera yendo en la misma dirección al mismo tiempo. Por lo tanto, antes de agregar el camino, verificaremos si hay un lugar para ello. public void AddRoad (HexDirection direction) { if (!roads[(int)direction] && !HasRiverThroughEdge(direction)) { SetRoad((int)direction, true); } }
Además, las carreteras no se pueden combinar con acantilados porque son demasiado afiladas. ¿O tal vez vale la pena allanar el camino a través de un acantilado bajo, pero no a través de un alto? Para determinar esto, necesitamos crear un método que nos indique la diferencia de altura en cierta dirección. public int GetElevationDifference (HexDirection direction) { int difference = elevation - GetNeighbor(direction).elevation; return difference >= 0 ? difference : -difference; }
Ahora podemos hacer que las carreteras se sumen a una diferencia de altura suficientemente pequeña. Me limitaré solo a las pendientes, es decir, un máximo de 1 unidad. public void AddRoad (HexDirection direction) { if ( !roads[(int)direction] && !HasRiverThroughEdge(direction) && GetElevationDifference(direction) <= 1 ) { SetRoad((int)direction, true); } }
Eliminar las carreteras equivocadas
Hicimos carreteras solo agregar cuando está permitido. Ahora debemos asegurarnos de que se eliminen si luego se vuelven incorrectos, por ejemplo, al agregar un río. Podemos prohibir la colocación de ríos en la parte superior de las carreteras, pero los ríos no se ven interrumpidos por las carreteras. Deja que laven el camino del camino.Será suficiente para nosotros pedir el camino false
, independientemente de si era el camino. Siempre se actualizará ambas células, de modo que ya no hay necesidad de llamar explícitamente RefreshSelfOnly
en SetOutgoingRiver
. public void SetOutgoingRiver (HexDirection direction) { if (hasOutgoingRiver && outgoingRiver == direction) { return; } HexCell neighbor = GetNeighbor(direction); if (!neighbor || elevation < neighbor.elevation) { return; } RemoveOutgoingRiver(); if (hasIncomingRiver && incomingRiver == direction) { RemoveIncomingRiver(); } hasOutgoingRiver = true; outgoingRiver = direction;
Otra operación que puede equivocar el camino es un cambio de altura. En este caso, tendremos que buscar carreteras en todas las direcciones. Si la diferencia de altura es demasiado grande, entonces se debe eliminar la carretera existente. public int Elevation { get { return elevation; } set { … for (int i = 0; i < roads.Length; i++) { if (roads[i] && GetElevationDifference((HexDirection)i) > 1) { SetRoad(i, false); } } Refresh(); } }
paquete de la unidadEdición de carreteras
Editar carreteras funciona igual que editar ríos. Por lo tanto HexMapEditor
, se requiere un interruptor más, más un método para establecer su estado. OptionalToggle riverMode, roadMode; public void SetRiverMode (int mode) { riverMode = (OptionalToggle)mode; } public void SetRoadMode (int mode) { roadMode = (OptionalToggle)mode; }
El método EditCell
ahora debería admitir la eliminación con la adición de carreteras. Esto significa que al arrastrar y soltar, puede realizar una de dos acciones posibles. Estamos reestructurando un poco el código para que, al realizar la operación de arrastrar y soltar correcta, se verifiquen los estados de ambos interruptores. void EditCell (HexCell cell) { if (cell) { if (applyColor) { cell.Color = activeColor; } if (applyElevation) { cell.Elevation = activeElevation; } if (riverMode == OptionalToggle.No) { cell.RemoveRiver(); } if (roadMode == OptionalToggle.No) { cell.RemoveRoads(); } if (isDrag) { HexCell otherCell = cell.GetNeighbor(dragDirection.Opposite()); if (otherCell) { if (riverMode == OptionalToggle.Yes) { otherCell.SetOutgoingRiver(dragDirection); } if (roadMode == OptionalToggle.Yes) { otherCell.AddRoad(dragDirection); } } } } }
Podemos agregar rápidamente una barra de ruta a la interfaz de usuario copiando la barra de río y cambiando el método llamado por los interruptores.Como resultado, tenemos una interfaz de usuario bastante alta. Para solucionar esto, cambié el diseño del panel de color para que se ajuste a los paneles más compactos de carreteras y ríos.IU con carreteras.Como ahora uso dos líneas de tres opciones para los colores, hay espacio para otro color. Así que agregué un artículo para naranja.Cinco colores: amarillo, verde, azul, naranja y blanco.Ahora podemos editar las carreteras, pero hasta ahora no son visibles. Puede usar el inspector para asegurarse de que todo esté funcionando.Celda con caminos en el inspector.paquete de la unidadTriangulación de carreteras
Para mostrar carreteras, debe triangularlas. Esto es similar a crear una malla para ríos, solo el cauce no aparecerá en el relieve.Primero, cree un nuevo sombreador estándar que usará nuevamente las coordenadas UV para pintar la superficie de la carretera. Shader "Custom/Road" { Properties { _Color ("Color", Color) = (1,1,1,1) _MainTex ("Albedo (RGB)", 2D) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Metallic ("Metallic", Range(0,1)) = 0.0 } SubShader { Tags { "RenderType"="Opaque" } LOD 200 CGPROGRAM #pragma surface surf Standard fullforwardshadows #pragma target 3.0 sampler2D _MainTex; struct Input { float2 uv_MainTex; }; half _Glossiness; half _Metallic; fixed4 _Color; void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = fixed4(IN.uv_MainTex, 1, 1); o.Albedo = c.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = ca; } ENDCG } FallBack "Diffuse" }
Crea un material de carretera con este sombreador.Camino material.Establezca la prefabricación del fragmento para que reciba otra malla secundaria de hexágonos para las carreteras. Esta malla no debe proyectar sombras y debe usar solo coordenadas UV. La forma más rápida de hacerlo es a través de una instancia prefabricada: duplicar el objeto Rivers y reemplazar su material.Carreteras de objetos secundarios.Después de eso, agréguelo al HexGridChunk
campo general HexMesh roads
e inclúyalo en Triangulate
. Conéctelo en el inspector con el objeto Carreteras . public HexMesh terrain, rivers, roads; public void Triangulate () { terrain.Clear(); rivers.Clear(); roads.Clear(); for (int i = 0; i < cells.Length; i++) { Triangulate(cells[i]); } terrain.Apply(); rivers.Apply(); roads.Apply(); }
El objeto Roads está conectado.Caminos entre celdas
Primero veamos los segmentos de camino entre las celdas. Al igual que los ríos, las carreteras están cerradas por dos quad medianos. Cubrimos completamente estos cuadrángulos de conexión con los cuadrángulos de carretera para que se puedan utilizar las posiciones de los mismos seis picos. Agregue para esto al HexGridChunk
método TriangulateRoadSegment
. void TriangulateRoadSegment ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, Vector3 v5, Vector3 v6 ) { roads.AddQuad(v1, v2, v4, v5); roads.AddQuad(v2, v3, v5, v6); }
Como ya no tenemos que preocuparnos por el flujo de agua, no se requiere la coordenada V, por lo que le asignamos el valor 0 en todas partes. Podemos usar la coordenada U para indicar si estamos en el medio de la carretera o de lado. Sea igual a 1 en el medio e igual a 0 en ambos lados. void TriangulateRoadSegment ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, Vector3 v5, Vector3 v6 ) { roads.AddQuad(v1, v2, v4, v5); roads.AddQuad(v2, v3, v5, v6); roads.AddQuadUV(0f, 1f, 0f, 0f); roads.AddQuadUV(1f, 0f, 0f, 0f); }
Un segmento de la carretera entre celdas.Sería lógico llamar a este método TriangulateEdgeStrip
, pero solo si realmente hay un camino. Agregue un parámetro booleano al método para pasar esta información. void TriangulateEdgeStrip ( EdgeVertices e1, Color c1, EdgeVertices e2, Color c2, bool hasRoad ) { … }
Por supuesto, ahora recibiremos errores del compilador, porque hasta ahora esta información aún no se ha transmitido. Como último argumento en todos los casos, TriangulateEdgeStrip
se puede agregar la llamada false
. Sin embargo, también podemos declarar que el valor predeterminado de este parámetro es igual false
. Debido a esto, el parámetro será opcional y los errores de compilación desaparecerán. void TriangulateEdgeStrip ( EdgeVertices e1, Color c1, EdgeVertices e2, Color c2, bool hasRoad = false ) { … }
¿Cómo funcionan los parámetros opcionales?, . ,
int MyMethod (int x = 1, int y = 2) { return x + y; }
int MyMethod (int x, int y) { return x + y; } int MyMethod (int x) { return MyMethod(x, 2); } int MyMethod () { return MyMethod(1, 2}; }
. . . .
Para triangular el camino, solo llame TriangulateRoadSegment
con los seis picos del medio, si es necesario. void TriangulateEdgeStrip ( EdgeVertices e1, Color c1, EdgeVertices e2, Color c2, bool hasRoad = false ) { terrain.AddQuad(e1.v1, e1.v2, e2.v1, e2.v2); terrain.AddQuadColor(c1, c2); terrain.AddQuad(e1.v2, e1.v3, e2.v2, e2.v3); terrain.AddQuadColor(c1, c2); terrain.AddQuad(e1.v3, e1.v4, e2.v3, e2.v4); terrain.AddQuadColor(c1, c2); terrain.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5); terrain.AddQuadColor(c1, c2); if (hasRoad) { TriangulateRoadSegment(e1.v2, e1.v3, e1.v4, e2.v2, e2.v3, e2.v4); } }
Así es como manejamos las conexiones de celda plana. Para apoyar los caminos en las repisas, también necesitamos decir TriangulateEdgeTerraces
dónde se debe agregar el camino. Él simplemente puede transmitir esta información TriangulateEdgeStrip
. void TriangulateEdgeTerraces ( EdgeVertices begin, HexCell beginCell, EdgeVertices end, HexCell endCell, bool hasRoad ) { EdgeVertices e2 = EdgeVertices.TerraceLerp(begin, end, 1); Color c2 = HexMetrics.TerraceLerp(beginCell.Color, endCell.Color, 1); TriangulateEdgeStrip(begin, beginCell.Color, e2, c2, hasRoad); for (int i = 2; i < HexMetrics.terraceSteps; i++) { EdgeVertices e1 = e2; Color c1 = c2; e2 = EdgeVertices.TerraceLerp(begin, end, i); c2 = HexMetrics.TerraceLerp(beginCell.Color, endCell.Color, i); TriangulateEdgeStrip(e1, c1, e2, c2, hasRoad); } TriangulateEdgeStrip(e2, c2, end, endCell.Color, hasRoad); }
TriangulateEdgeTerraces
llamado por dentro TriangulateConnection
. Es aquí donde podemos determinar si realmente hay un camino que va en la dirección actual, tanto durante la triangulación de la costilla como en la triangulación de las repisas. if (cell.GetEdgeType(direction) == HexEdgeType.Slope) { TriangulateEdgeTerraces( e1, cell, e2, neighbor, cell.HasRoadThroughEdge(direction) ); } else { TriangulateEdgeStrip( e1, cell.Color, e2, neighbor.Color, cell.HasRoadThroughEdge(direction) ); }
Segmentos de carretera entre celdas.Celda sobre renderizado
Al dibujar carreteras, verá que aparecen segmentos de carretera entre celdas. El centro de estos segmentos será de color púrpura con una transición a azul en los bordes.Sin embargo, cuando mueve la cámara, los segmentos pueden parpadear y, a veces, desaparecer por completo. Esto se debe a que los triángulos de las carreteras se superponen exactamente a los triángulos del terreno. Los triángulos para renderizar se seleccionan aleatoriamente. Este problema se puede solucionar en dos etapas.En primer lugar, queremos dibujar los caminos después de que se dibuja el relieve. Esto se puede lograr renderizándolos después de renderizar la geometría habitual, es decir, colocándolos en una cola de renderizado posterior. Tags { "RenderType"="Opaque" "Queue" = "Geometry+1" }
En segundo lugar, debemos asegurarnos de que las carreteras se dibujan sobre triángulos de terreno en la misma posición. Esto se puede hacer agregando el desplazamiento de prueba de profundidad. Permitirá que la GPU suponga que los triángulos están más cerca de la cámara de lo que realmente están. Tags { "RenderType"="Opaque" "Queue" = "Geometry+1" } LOD 200 Offset -1, -1
Caminos a través de celdas
Al triangular ríos, tuvimos que lidiar con no más de dos direcciones de río por celda. Podríamos identificar cinco opciones posibles y triangularlas de manera diferente para crear los ríos de aspecto correcto. Sin embargo, en el caso de las carreteras, hay catorce opciones posibles. No utilizaremos enfoques separados para cada una de estas opciones. En su lugar, procesaremos cada una de las seis direcciones de celda de la misma manera, independientemente de la configuración específica del camino.Cuando una carretera pasa a lo largo de una parte de la celda, la dibujaremos directamente al centro de la celda, sin abandonar la zona de triángulos. Dibujaremos un segmento de la carretera desde el borde hasta la mitad en la dirección del centro. Luego usamos dos triángulos para cerrar el resto al centro.Triangulación de una parte del camino.Para triangular este esquema, necesitamos conocer el centro de la celda, los vértices medios izquierdo y derecho y los vértices del borde. Agregue un método TriangulateRoad
con los parámetros apropiados. void TriangulateRoad ( Vector3 center, Vector3 mL, Vector3 mR, EdgeVertices e ) { }
Para construir un segmento de carretera, necesitamos un pico adicional. Se encuentra entre los picos medios izquierdo y derecho. void TriangulateRoad ( Vector3 center, Vector3 mL, Vector3 mR, EdgeVertices e ) { Vector3 mC = Vector3.Lerp(mL, mR, 0.5f); TriangulateRoadSegment(mL, mC, mR, e.v2, e.v3, e.v4); }
Ahora también podemos agregar los dos triángulos restantes. TriangulateRoadSegment(mL, mC, mR, e.v2, e.v3, e.v4); roads.AddTriangle(center, mL, mC); roads.AddTriangle(center, mC, mR);
También necesitamos agregar las coordenadas UV de los triángulos. Dos de sus picos están en el medio del camino, y el resto está en el borde. roads.AddTriangle(center, mL, mC); roads.AddTriangle(center, mC, mR); roads.AddTriangleUV( new Vector2(1f, 0f), new Vector2(0f, 0f), new Vector2(1f, 0f) ); roads.AddTriangleUV( new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(0f, 0f) );
Por ahora, limitémonos a celdas en las que no hay ríos. En estos casos, Triangulate
simplemente crea un abanico de triángulos. Mueva este código a un método separado. Luego agregamos una llamada TriangulateRoad
cuando el camino realmente es. Los vértices medios izquierdo y derecho se pueden encontrar por interpolación entre el centro y los dos vértices de la esquina. void Triangulate (HexDirection direction, HexCell cell) { … if (cell.HasRiver) { … } else { TriangulateWithoutRiver(direction, cell, center, e); } … } void TriangulateWithoutRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { TriangulateEdgeFan(center, e, cell.Color); if (cell.HasRoadThroughEdge(direction)) { TriangulateRoad( center, Vector3.Lerp(center, e.v1, 0.5f), Vector3.Lerp(center, e.v5, 0.5f), e ); } }
Caminos que pasan por las celdas.Costillas de carretera
Ahora podemos ver los caminos, pero más cerca del centro de las celdas se estrechan. Como no verificamos con cuál de las catorce opciones que estamos tratando, no podemos cambiar el centro del camino para crear formas más hermosas. En cambio, podemos agregar bordes de carretera adicionales en otras partes de la celda.Cuando las carreteras pasan a través de la celda, pero no en la dirección actual, agregaremos un triángulo a los bordes de la carretera. Este triángulo está definido por los vértices medios central, izquierdo y derecho. En este caso, solo el pico central se encuentra en el medio del camino. Los otros dos yacen sobre su costilla. void TriangulateRoadEdge (Vector3 center, Vector3 mL, Vector3 mR) { roads.AddTriangle(center, mL, mR); roads.AddTriangleUV( new Vector2(1f, 0f), new Vector2(0f, 0f), new Vector2(0f, 0f) ); }
Parte del borde de la carretera.Cuando necesitamos triangular un camino completo o solo un borde, debemos dejarlo para TriangulateRoad
. Para hacer esto, este método debe saber si el camino pasa a través de la dirección del borde actual de la celda. Por lo tanto, agregamos un parámetro para esto. void TriangulateRoad ( Vector3 center, Vector3 mL, Vector3 mR, EdgeVertices e, bool hasRoadThroughCellEdge ) { if (hasRoadThroughCellEdge) { Vector3 mC = Vector3.Lerp(mL, mR, 0.5f); TriangulateRoadSegment(mL, mC, mR, e.v2, e.v3, e.v4); roads.AddTriangle(center, mL, mC); roads.AddTriangle(center, mC, mR); roads.AddTriangleUV( new Vector2(1f, 0f), new Vector2(0f, 0f), new Vector2(1f, 0f) ); roads.AddTriangleUV( new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(0f, 0f) ); } else { TriangulateRoadEdge(center, mL, mR); } }
Ahora TriangulateWithoutRiver
tendrá que llamar TriangulateRoad
cuando alguna carretera pase por la celda. Y tendrá que transmitir información sobre si el camino pasa por el borde actual. void TriangulateWithoutRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { TriangulateEdgeFan(center, e, cell.Color); if (cell.HasRoads) { TriangulateRoad( center, Vector3.Lerp(center, e.v1, 0.5f), Vector3.Lerp(center, e.v5, 0.5f), e, cell.HasRoadThroughEdge(direction) ); } }
Caminos con costillas terminadas.Suavizado de carreteras
Los caminos ya están completos. Desafortunadamente, este enfoque crea protuberancias en el centro de las células. Colocar los picos izquierdo y derecho en el medio entre el centro y las esquinas nos conviene cuando hay un camino adyacente a ellos. Pero si no es así, entonces hay un bulto. Para evitar esto, en tales casos podemos colocar los vértices más cerca del centro. Más específicamente, luego interpolar con ¼, no con ½.Creemos un método separado para determinar qué interpoladores usar. Como hay dos de ellos, podemos poner el resultado Vector2
. Su componente X será el interpolador del punto izquierdo, y el componente Y será el interpolador del punto derecho. Vector2 GetRoadInterpolators (HexDirection direction, HexCell cell) { Vector2 interpolators; return interpolators; }
Si hay un camino en la dirección actual, podemos colocar los puntos en el medio. Vector2 GetRoadInterpolators (HexDirection direction, HexCell cell) { Vector2 interpolators; if (cell.HasRoadThroughEdge(direction)) { interpolators.x = interpolators.y = 0.5f; } return interpolators; }
De lo contrario, las opciones pueden ser diferentes. Para el punto izquierdo, podemos usar ½ si hay un camino en la dirección anterior. Si no es así, entonces debemos usar ¼. Lo mismo se aplica al punto correcto, pero teniendo en cuenta la siguiente dirección. Vector2 GetRoadInterpolators (HexDirection direction, HexCell cell) { Vector2 interpolators; if (cell.HasRoadThroughEdge(direction)) { interpolators.x = interpolators.y = 0.5f; } else { interpolators.x = cell.HasRoadThroughEdge(direction.Previous()) ? 0.5f : 0.25f; interpolators.y = cell.HasRoadThroughEdge(direction.Next()) ? 0.5f : 0.25f; } return interpolators; }
Ahora puede usar este nuevo método para determinar qué interpoladores se usan. Gracias a esto, los caminos se allanarán. void TriangulateWithoutRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { TriangulateEdgeFan(center, e, cell.Color); if (cell.HasRoads) { Vector2 interpolators = GetRoadInterpolators(direction, cell); TriangulateRoad( center, Vector3.Lerp(center, e.v1, interpolators.x), Vector3.Lerp(center, e.v5, interpolators.y), e, cell.HasRoadThroughEdge(direction) ); } }
Caminos lisos.paquete de la unidadLa combinación de ríos y caminos.
En la etapa actual, tenemos caminos funcionales, pero solo si no hay ríos. Si hay un río en la celda, entonces las carreteras no serán trianguladas.No hay caminos cerca de los ríos.Creemos un método TriangulateRoadAdjacentToRiver
para manejar esta situación. Lo configuramos con los parámetros habituales. Lo llamaremos al comienzo del método TriangulateAdjacentToRiver
. void TriangulateAdjacentToRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { if (cell.HasRoads) { TriangulateRoadAdjacentToRiver(direction, cell, center, e); } … } void TriangulateRoadAdjacentToRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { }
Para empezar, haremos lo mismo que para las carreteras sin ríos. Verificaremos si el camino pasa por el borde actual, obtendremos interpoladores, crearemos picos intermedios y llamaremos TriangulateRoad
. Pero como los ríos aparecerán en el camino, necesitamos alejar los caminos de ellos. Como resultado, el centro de la carretera estará en una posición diferente. Usamos una variable para almacenar esta nueva posición roadCenter
. Inicialmente, será igual al centro de la celda. void TriangulateRoadAdjacentToRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { bool hasRoadThroughEdge = cell.HasRoadThroughEdge(direction); Vector2 interpolators = GetRoadInterpolators(direction, cell); Vector3 roadCenter = center; Vector3 mL = Vector3.Lerp(roadCenter, e.v1, interpolators.x); Vector3 mR = Vector3.Lerp(roadCenter, e.v5, interpolators.y); TriangulateRoad(roadCenter, mL, mR, e, hasRoadThroughEdge); }
Entonces crearemos caminos parciales en celdas con ríos. Las direcciones a través de las cuales pasan los ríos atravesarán las brechas en los caminos.Caminos con espacios.Principio o fin del río.
Primero veamos las celdas que contienen el principio o el final de un río. Para que los caminos no se superpongan con el agua, muevamos el centro del camino desde el río. Para obtener la dirección del río entrante o saliente, agregue la HexCell
propiedad. public HexDirection RiverBeginOrEndDirection { get { return hasIncomingRiver ? incomingRiver : outgoingRiver; } }
Ahora podemos usar esta propiedad HexGridChunk.TriangulateRoadAdjacentToRiver
para mover el centro de la carretera en la dirección opuesta. Será suficiente mover un tercio de la costilla central en esta dirección. bool hasRoadThroughEdge = cell.HasRoadThroughEdge(direction); Vector2 interpolators = GetRoadInterpolators(direction, cell); Vector3 roadCenter = center; if (cell.HasRiverBeginOrEnd) { roadCenter += HexMetrics.GetSolidEdgeMiddle( cell.RiverBeginOrEndDirection.Opposite() ) * (1f / 3f); } Vector3 mL = Vector3.Lerp(roadCenter, e.v1, interpolators.x); Vector3 mR = Vector3.Lerp(roadCenter, e.v5, interpolators.y); TriangulateRoad(roadCenter, mL, mR, e, hasRoadThroughEdge);
Carreteras modificadas.Luego necesitamos cerrar las brechas. Haremos esto agregando triángulos adicionales a los bordes de la carretera cuando estemos cerca del río. Si hay un río en la dirección anterior, entonces agregamos un triángulo entre el centro de la carretera, el centro de la celda y el punto medio izquierdo. Y si el río está en la siguiente dirección, entonces agregamos un triángulo entre el centro de la carretera, el punto medio derecho y el centro de la celda.Haremos esto independientemente de la configuración del río, así que coloque este código al final del método. Vector3 mL = Vector3.Lerp(roadCenter, e.v1, interpolators.x); Vector3 mR = Vector3.Lerp(roadCenter, e.v5, interpolators.y); TriangulateRoad(roadCenter, mL, mR, e, hasRoadThroughEdge); if (cell.HasRiverThroughEdge(direction.Previous())) { TriangulateRoadEdge(roadCenter, center, mL); } if (cell.HasRiverThroughEdge(direction.Next())) { TriangulateRoadEdge(roadCenter, mR, center); }
¿No puedes usar la declaración else?. , .
Carreteras preparadas.Ríos rectos
Las celdas con ríos rectos son particularmente difíciles porque esencialmente dividen el centro de la celda en dos. Ya agregamos triángulos adicionales para llenar los espacios entre los ríos, pero también tenemos que desconectar las carreteras en lados opuestos del río.Caminos que se superponen a un río recto.Si la celda no tiene el principio o el final del río, entonces podemos verificar si los ríos entrantes y salientes van en direcciones opuestas. Si es así, entonces tenemos un río directo. if (cell.HasRiverBeginOrEnd) { roadCenter += HexMetrics.GetSolidEdgeMiddle( cell.RiverBeginOrEndDirection.Opposite() ) * (1f / 3f); } else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) { }
Para determinar dónde está el río en relación con la dirección actual, necesitamos verificar las direcciones vecinas. El río está a la izquierda o a la derecha. Como hacemos esto al final del método, almacenamos en caché estas solicitudes en variables booleanas. Esto también simplificará la lectura de nuestro código. bool hasRoadThroughEdge = cell.HasRoadThroughEdge(direction); bool previousHasRiver = cell.HasRiverThroughEdge(direction.Previous()); bool nextHasRiver = cell.HasRiverThroughEdge(direction.Next()); Vector2 interpolators = GetRoadInterpolators(direction, cell); Vector3 roadCenter = center; if (cell.HasRiverBeginOrEnd) { roadCenter += HexMetrics.GetSolidEdgeMiddle( cell.RiverBeginOrEndDirection.Opposite() ) * (1f / 3f); } else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) { if (previousHasRiver) { } else { } } Vector3 mL = Vector3.Lerp(roadCenter, e.v1, interpolators.x); Vector3 mR = Vector3.Lerp(roadCenter, e.v5, interpolators.y); TriangulateRoad(roadCenter, mL, mR, e, hasRoadThroughEdge); if (previousHasRiver) { TriangulateRoadEdge(roadCenter, center, mL); } if (nextHasRiver) { TriangulateRoadEdge(roadCenter, mR, center); }
Necesitamos cambiar el centro del camino a un vector angular que apunte en la dirección opuesta al río. Si el río pasa por la dirección anterior, este es el segundo ángulo sólido. De lo contrario, este es el primer ángulo sólido. else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) { Vector3 corner; if (previousHasRiver) { corner = HexMetrics.GetSecondSolidCorner(direction); } else { corner = HexMetrics.GetFirstSolidCorner(direction); } }
Para mover el camino de manera que esté adyacente al río, necesitamos mover el centro del camino la mitad de la distancia a esta esquina. Luego también tenemos que mover el centro de la celda un cuarto de la distancia en esa dirección. else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) { Vector3 corner; if (previousHasRiver) { corner = HexMetrics.GetSecondSolidCorner(direction); } else { corner = HexMetrics.GetFirstSolidCorner(direction); } roadCenter += corner * 0.5f; center += corner * 0.25f; }
Caminos divididos.Compartimos una red de carreteras dentro de esta celda. Esto es normal cuando las carreteras están a ambos lados del río. Pero si en un lado no hay camino, tendremos un pequeño pedazo de camino aislado. Esto es ilógico, así que eliminemos esas partes.Asegúrese de que haya una carretera en la dirección actual. Si no es así, verifique la otra dirección del mismo lado del río para ver la presencia de la carretera. Si no hay una carretera de paso, ya sea allí o allí, salimos del método antes de triangular. if (previousHasRiver) { if ( !hasRoadThroughEdge && !cell.HasRoadThroughEdge(direction.Next()) ) { return; } corner = HexMetrics.GetSecondSolidCorner(direction); } else { if ( !hasRoadThroughEdge && !cell.HasRoadThroughEdge(direction.Previous()) ) { return; } corner = HexMetrics.GetFirstSolidCorner(direction); }
Carreteras truncadas.¿Qué pasa con los puentes?. .
Ríos en zigzag
El siguiente tipo de río son los zigzags. Dichos ríos no comparten la red de carreteras, por lo que solo necesitamos mover el centro de la carretera.Zigzags pasando por las carreteras.La forma más fácil de verificar si hay zigzags es comparando las direcciones de los ríos entrantes y salientes. Si son adyacentes, entonces tenemos un zigzag. Esto lleva a dos opciones posibles, dependiendo de la dirección del flujo. if (cell.HasRiverBeginOrEnd) { … } else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) { … } else if (cell.IncomingRiver == cell.OutgoingRiver.Previous()) { } else if (cell.IncomingRiver == cell.OutgoingRiver.Next()) { }
Podemos mover el centro del camino usando una de las esquinas de la dirección del río entrante. El ángulo que seleccione depende de la dirección del flujo. Mueva el centro de la carretera desde este ángulo con un factor de 0.2. else if (cell.IncomingRiver == cell.OutgoingRiver.Previous()) { roadCenter -= HexMetrics.GetSecondCorner(cell.IncomingRiver) * 0.2f; } else if (cell.IncomingRiver == cell.OutgoingRiver.Next()) { roadCenter -= HexMetrics.GetFirstCorner(cell.IncomingRiver) * 0.2f; }
El camino se alejó de los zigzags.Dentro de los ríos torcidos
La última configuración del río es una curva suave. Al igual que con el río directo, este también puede separar caminos. Pero en este caso, las partes serán diferentes. Primero necesitamos trabajar con el interior de la curva.Un río curvo con caminos pavimentados.Cuando tenemos un río a ambos lados de la dirección actual, entonces estamos dentro de la curva. else if (cell.IncomingRiver == cell.OutgoingRiver.Next()) { … } else if (previousHasRiver && nextHasRiver) { }
Necesitamos mover el centro del camino hacia el borde actual de la celda, acortando un poco el camino. Un coeficiente de 0.7 servirá. El centro de la celda también debe cambiar con un coeficiente de 0.5. else if (previousHasRiver && nextHasRiver) { Vector3 offset = HexMetrics.GetSolidEdgeMiddle(direction) * HexMetrics.innerToOuter; roadCenter += offset * 0.7f; center += offset * 0.5f; }
Carreteras acortadas.Como en el caso de los ríos rectos, tendremos que cortar las partes aisladas de las carreteras. En este caso, es suficiente verificar solo la dirección actual. else if (previousHasRiver && nextHasRiver) { if (!hasRoadThroughEdge) { return; } Vector3 offset = HexMetrics.GetSolidEdgeMiddle(direction) * HexMetrics.innerToOuter; roadCenter += offset * 0.7f; center += offset * 0.5f; }
Cortar carreteras.Fuera de los ríos torcidos
Después de verificar todos los casos anteriores, la única opción restante era la parte exterior del río curvo. Afuera hay tres partes de la celda. Necesitamos encontrar la dirección del medio. Una vez recibido, podemos mover el centro del camino hacia esta costilla por un factor de 0.25. else if (previousHasRiver && nextHasRiver) { … } else { HexDirection middle; if (previousHasRiver) { middle = direction.Next(); } else if (nextHasRiver) { middle = direction.Previous(); } else { middle = direction; } roadCenter += HexMetrics.GetSolidEdgeMiddle(middle) * 0.25f; }
Cambió el exterior de la carretera.Como último paso, necesitamos truncar los caminos en este lado del río. La forma más fácil es verificar las tres direcciones de la carretera en relación con el medio. Si no hay carreteras, dejamos de trabajar. else { HexDirection middle; if (previousHasRiver) { middle = direction.Next(); } else if (nextHasRiver) { middle = direction.Previous(); } else { middle = direction; } if ( !cell.HasRoadThroughEdge(middle) && !cell.HasRoadThroughEdge(middle.Previous()) && !cell.HasRoadThroughEdge(middle.Next()) ) { return; } roadCenter += HexMetrics.GetSolidEdgeMiddle(middle) * 0.25f; }
Caminos antes y después del recorte.Después de procesar todas las opciones de río, nuestros ríos y carreteras pueden coexistir. Los ríos ignoran los caminos, y los caminos se adaptan a los ríos.La combinación de ríos y caminos.paquete de la unidadLa apariencia de los caminos.
Hasta ese momento, utilizamos sus coordenadas UV como colores de carretera. Como solo cambió la coordenada U, en realidad mostramos la transición entre el centro y el borde de la carretera.Visualización de coordenadas UV.Ahora que las carreteras están exactamente trianguladas correctamente, podemos cambiar el sombreador de carreteras para que represente algo más parecido a las carreteras. Como en el caso de los ríos, esta será una visualización simple, sin lujos.Comenzaremos utilizando colores sólidos para carreteras. Solo usa el color del material. Lo hice rojo. void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = _Color; o.Albedo = c.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = ca; }
Caminos rojos.¡Y ya se ve mucho mejor! Pero continuemos y mezclemos el camino con el terreno, usando la coordenada U como factor de mezcla. void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = _Color; float blend = IN.uv_MainTex.x; o.Albedo = c.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = blend; }
Parece que esto no ha cambiado nada. Sucedió porque nuestro sombreador es opaco. Ahora necesita una mezcla alfa. En particular, necesitamos un sombreador para una superficie de calcomanía de acoplamiento. Podemos obtener el sombreador requerido agregando una #pragma surface
línea a la directiva decal:blend
. #pragma surface surf Standard fullforwardshadows decal:blend
La mezcla de caminos.Así que creamos una mezcla lineal suave de medio a borde que no se ve muy bonita. Para que parezca una carretera, necesitamos un área sólida, seguida de una transición rápida a un área opaca. Puede usar la función para esto smoothstep
. Convierte una progresión lineal de 0 a 1 en una curva en forma de S.Progresión lineal y paso suave.La función smoothstep
tiene un parámetro mínimo y máximo para ajustar la curva en un intervalo arbitrario. Los valores de entrada fuera del rango están limitados para mantener la curva plana. Usemos 0.4 al comienzo de la curva y 0.7 al final. Esto significa que la coordenada U de 0 a 0.4 será completamente transparente. Y las coordenadas U de 0.7 a 1 serán completamente opacas. La transición ocurre entre 0.4 y 0.7. float blend = IN.uv_MainTex.x; blend = smoothstep(0.4, 0.7, blend);
Transición rápida entre áreas opacas y transparentes.Camino con ruido
Dado que la malla de la carretera estará distorsionada, las carreteras tienen diferentes anchuras. Por lo tanto, el ancho de la transición en los bordes también será variable. A veces es borroso, a veces duro. Tal variabilidad nos conviene si percibimos los caminos como arenosos o terrosos.Pasemos al siguiente paso y agreguemos ruido a los bordes de la carretera. Esto los hará más desiguales y menos poligonales. Podemos hacer esto muestreando la textura de ruido. Para el muestreo, puede usar las coordenadas del mundo XZ, tal como lo hicimos al distorsionar los vértices de las celdas.Para obtener acceso a la posición del mundo en el sombreador de superficie, agregue a la estructura de entrada float3 worldPos
. struct Input { float2 uv_MainTex; float3 worldPos; };
Ahora podemos usar esta posición surf
para muestrear la textura principal. Alejar también, de lo contrario la textura se repetirá con demasiada frecuencia. float4 noise = tex2D(_MainTex, IN.worldPos.xz * 0.025); fixed4 c = _Color; float blend = IN.uv_MainTex.x;
Distorsionamos la transición multiplicando la coordenada U por noise.x
. Pero dado que los valores de ruido son en promedio 0.5, la mayoría de las carreteras desaparecerán. Para evitar esto, agregue 0.5 al ruido antes de la multiplicación. float blend = IN.uv_MainTex.x; blend *= noise.x + 0.5; blend = smoothstep(0.4, 0.7, blend);
Bordes distorsionados de la carretera.Para finalizar esto, también distorsionaremos el color de las carreteras. Esto le dará a las carreteras una sensación de suciedad correspondiente a bordes borrosos.Multiplique el color por otro canal de ruido, digamos por noise.y
. Entonces obtenemos un promedio de la mitad del valor de color. Como esto es demasiado, reduciremos ligeramente la escala de ruido y agregaremos una constante para que la suma pueda alcanzar 1. fixed4 c = _Color * (noise.y * 0.75 + 0.25);
Caminos ásperos.paquete de la unidad