Estoy trabajando en un motor de renderizado de terreno en tiempo real. Tengo clases de QuadTree y Node. La clase QuadTree expande / colapsa un árbol dependiendo de dónde esté la cámara. Por lo tanto, tiene sentido que QuadTree sea responsable de la vida útil de los objetos Node. El problema es que hay muchos datos que deben asociarse con esa vida útil y que no tienen nada que ver con QuadTree. Es posible que estos datos ni siquiera estén relacionados entre sí. He estado buscando una forma limpia de desacoplar mis clases correctamente sin éxito. Todos los cambios (a veces incluso los menores) requieren cambios a través de varios archivos, la mayoría de las veces con archivos no relacionados con él. Finalmente tengo algo que parece funcionar, pero creo que he estado tratando de desacoplarlo demasiado, lo que aumentó la complejidad por no tener muchos beneficios. Así es como lo hice:
Mi clase QuadTree no debería tratar con nada más que el árbol. Pero cada vez que creo un nodo, necesito asociar datos con estos nodos. Como esto contaminaría la clase para hacerlo en la clase QuadTree, agregué una interfaz para comunicarse entre QuadTree y la clase cuyo trabajo es crear estos datos. En este punto, creo que lo he estado haciendo de la manera correcta. Pseudocódigo:
class QTInterface
{
virtual void nodeCreated( Node& node ) = 0;
virtual void nodeDestroyed( Node& node ) = 0;
};
class QuadTree
{
public:
QuadTree( ...., QTInterface& i ) : i( i ) {}
void update( Camera camera )
{
// ....
i.nodeCreated( node );
// ....
i.nodeDestroyed( node );
}
private:
QTInterface& i;
Node root;
};
Ahora necesito asociar algunos datos aleatorios a cada uno de estos nodos. Entonces, en mi clase que implementa QTInterface, tengo un mapa que hace exactamente eso:
class Terrain : public QTInterface
{
void nodeCreated( Node node )
{
Data data;
// ... create all the data associated to this node
map[ node ] = data
// One more thing, The QuadTree actually needs one field of Data to continue, so I fill it there
node.xxx = data.xxx
}
void nodeDestroyed( Node node )
{
// ... destroy all the data associated to this node
map.erase( node );
}
};
Node y QuadTree ahora son independientes de otras partes del código y si tengo que volver allí, solo será porque tengo que cambiar algo en el algoritmo QuadTree.
Pero ahí es donde creo que tengo mi primer problema. La mayoría de las veces no me preocupo por la optimización hasta que la veo, pero creo que si tengo que agregar este tipo de sobrecarga para desacoplar mis clases correctamente, es porque el diseño tiene fallas.
Otro problema con esto es que los datos asociados con el nodo terminarán siendo una bolsa de muchos datos. Para obtener los mismos resultados con menos dolor, podría haber usado la clase Node como una bolsa.
Entonces, varias preguntas aquí:
¿Estoy complicando demasiado las cosas? ¿Debería haber extendido la clase Node, convirtiéndola en una bolsa de datos que algunas clases utilizan?
Si no, ¿qué tan buena es mi alternativa? ¿Hay una mejor manera?
Siempre tengo dificultades para desacoplar mis clases correctamente. ¿Algún consejo para dar que pueda usar más tarde? (Por ejemplo, ¿qué preguntas tengo que hacerme o cómo se procesa? Pensar en esto en papel me parece muy abstracto e inmediatamente codificar algo da como resultado una refactorización posterior)
Nota: Traté de simplificar el problema tanto como pude para evitar una pregunta muy larga llena de detalles innecesarios. Espero no haber omitido los importantes.
Editar: se han pedido algunos detalles:
La cámara no solo puede elegir los nodos visibles porque significaría que tengo que mantener todos los nodos en la memoria, lo que no es posible porque se supone que el motor representa terrenos muy grandes con una alta resolución. La profundidad del árbol sería fácilmente de más de 25. Aparte de eso, también es más fácil saber cuándo se han creado / destruido nuevos nodos (básicamente como lo ha sido más fácil: si el nodo no tiene hijos y la profundidad no es 0, eso es porque el nodo necesita ser creado, si el nodo tiene hijos y el algoritmo se detiene allí, significa que antes eran visibles el marco pero no ahora, así que tengo que eliminarlos y los datos vinculados a él).
Un ejemplo de datos que deben calcularse es la altura y las normales de estos nodos ( https://en.wikipedia.org/wiki/Heightmap y https://en.wikipedia.org/wiki/Normal_mapping ).
Crear estos datos implica:
- Enviar datos de nodo calculados por QuadTree a una cola de trabajo multiproceso
- Una vez que se ha generado el mapa de altura, actualice el único campo del nodo que QuadTree necesita para continuar con el algoritmo: la altura mínima / máxima.
- Después de eso, actualice las texturas de la GPU utilizando el mapa de altura y el mapa normal calculados en la CPU.
Pero esta es solo la forma de calcular los datos. También puedo hacerlo en la GPU, y requerirá pasos completamente diferentes. Y esa es una razón por la que quiero desacoplarlo de la clase QuadTree, porque me gustaría cambiar fácilmente entre los dos (con fines de prueba) sin tener que refactorizar todo mi código. acoplamiento de diseño
Respuestas:
Para asociar y disociar dinámicamente datos sobre la marcha, independientemente de la vida útil de un nodo QT, mientras que el QT combinado con la cámara tiene conocimiento de cuándo los datos deben asociarse / desasociarse sobre la marcha, es un poco difícil de generalizar y creo que su solución es En realidad no está mal. Es algo difícil de diseñar de una manera muy agradable y generalizada. como, "uhh ... ¡pruébalo bien y envíalo!" De acuerdo, un poco de broma. Trataré de ofrecer un poco de pensamiento para explorar. Una de las cosas que más me fulminó fue aquí:
Esto me dice que una referencia / puntero de nodo no solo se usa como clave en un contenedor asociativo externo. En realidad, está accediendo y modificando componentes internos del nodo quadtree fuera del quadtree mismo. Y debería haber una manera bastante fácil de evitar al menos eso para empezar. Si ese es el único lugar donde está modificando los componentes internos del nodo fuera del quadtree, entonces podría hacer esto (digamos que
xxx
es un par de flotadores):En ese momento, el quadtree puede usar el valor de retorno de esta función para asignar
xxx
. Eso ya afloja bastante el acoplamiento cuando ya no estás accediendo a las partes internas de un nodo de árbol fuera del árbol.Eliminar la necesidad de
Terrain
acceder a los componentes internos del árbol cuádruple en realidad eliminaría el único lugar donde está acoplando cosas innecesariamente. Es la única PITA real si cambia las cosas con una implementación de GPU, por ejemplo, ya que la implementación de GPU podría usar un representante interno totalmente diferente para los nodos.Pero para sus inquietudes de rendimiento, y tengo muchas más ideas sobre cómo lograr el máximo desacoplamiento con este tipo de cosas, sugeriría una representación muy diferente en la que puede convertir la asociación / disociación de datos en una operación barata de tiempo constante. Es un poco difícil de explicar a alguien que no está acostumbrado a construir contenedores estándar que requieren una ubicación nueva para construir elementos en su lugar desde la memoria agrupada, así que comenzaré con algunos datos:
Eso es básicamente una "lista libre indexada". Pero cuando usa este representante para los datos asociados, puede hacer algo como esto:
Con suerte, todo esto tiene sentido, y naturalmente está un poco más desacoplado de su diseño original, ya que no requiere que los clientes tengan acceso interno a los campos de nodo de árbol (ahora ya no requiere conocimiento de nodos, ni siquiera para usarlo como claves ), y es considerablemente más eficiente ya que puede asociar y disociar datos a / desde nodos en tiempo constante (y sin usar una tabla hash, lo que implicaría una constante mucho mayor). Espero que sus datos se puedan alinear usando
max_align_t
(sin campos SIMD, por ejemplo) y sean copiables trivialmente, de lo contrario las cosas se vuelven considerablemente más complejas ya que necesitaríamos un asignador alineado y podríamos tener que rodar nuestro propio contenedor de listas libres. Bueno, si solo tiene tipos copiables de forma no trivial y no necesita más demax_align_t
, podemos usar una implementación de puntero de lista libre que agrupa y vincula nodos desenrollados que almacenanK
elementos de datos para evitar la necesidad de reasignar bloques de memoria existentes. Puedo mostrar eso si necesitas una alternativa así.Es un poco avanzado y muy específico de C ++, considerando la idea de asignar y liberar memoria para elementos como una tarea separada de construirlos y destruirlos. Pero si lo hace de esta manera,
Terrain
absorbe las responsabilidades mínimas y ya no requiere ningún conocimiento interno de la representación del árbol, ni siquiera maneja los nodos opacos. Sin embargo, este nivel de control de memoria suele ser lo que necesita si desea diseñar las estructuras de datos más eficientes.La idea fundamental es que el cliente utiliza el pase de árbol en el tamaño de tipo de los datos que desea asociar / desasociar sobre la marcha al quadtree ctor. Entonces el quadtree tiene la responsabilidad de asignar y liberar memoria usando ese tamaño de letra. Luego, pasa la responsabilidad de construir y destruir los datos al cliente mediante
QTInterface
un despacho dinámico. La única responsabilidad, por lo tanto, fuera del árbol que todavía está relacionado con el árbol, es construir y destruir elementos de la memoria que el quadtree se asigna y desasigna. En ese punto, las dependencias se vuelven así:Lo cual es muy razonable teniendo en cuenta la dificultad de lo que está haciendo y la escala de las entradas. Básicamente, su
Terrain
entonces solo depende deQuadtree
yQTInterface
, y ya no son las partes internas del quadtree o sus nodos. Anteriormente tenías esto:Y, por supuesto, un problema evidente con eso, especialmente si está considerando probar implementaciones de GPU, es esa dependencia de
Terrain
aNode
, ya que una implementación de GPU probablemente querría usar un representante de nodo muy diferente. Por supuesto, si quieres ser SÓLIDO, harías algo como esto:... junto con posiblemente una fábrica. Pero la IMO es una exageración total (al menos
INode
es una exageración total de la IMO) y no sería muy útil en un caso tan granular como una función quadtree si cada una requiere un despacho dinámico.En términos generales y crudos, el desacoplamiento a menudo se reduce a limitar la cantidad de información que una clase o función particular requiere sobre otra cosa para hacer lo suyo.
Supongo que está usando C ++ ya que ningún otro lenguaje que conozco tiene esa sintaxis exacta, y en C ++ un mecanismo de desacoplamiento muy efectivo para las estructuras de datos son las plantillas de clase con polimorfismo estático si puede usarlas. Si considera los contenedores estándar como
std::vector<T, Alloc>
, vector no está acoplado a lo que especifique paraT
nada. Solo requiere queT
satisfaga algunos requisitos básicos de la interfaz, como que es construible por copia y tiene un constructor predeterminado para el constructor de relleno y el cambio de tamaño de relleno. Y nunca requerirá cambios como resultado de losT
cambios.Entonces, al vincularlo con lo anterior, permite que la estructura de datos se implemente utilizando el conocimiento mínimo absoluto de lo que contiene, y eso lo desacopla en la medida en que ni siquiera necesita ningún tipo de información por adelantado (avance aquí es hablando en términos de dependencias / acoplamiento de código, no información en tiempo de compilación) sobre lo que
T
es.La segunda forma más práctica de minimizar la cantidad de información requerida es usar polimorfismo dinámico. Por ejemplo, si desea implementar una estructura de datos razonablemente generalizada que minimice el conocimiento de lo que almacena, puede capturar los requisitos de interfaz para lo que almacena en una o más interfaces:
Pero de cualquier manera se reduce a minimizar la cantidad de información que necesita de antemano mediante la codificación de interfaces en lugar de detalles concretos. Aquí, lo único que está haciendo que parece requerir mucha más información de la requerida es que
Terrain
debe tener información completa sobre las partes internas de un nodo Quadtree, por ejemplo, en tal caso, suponiendo que la única razón por la que necesita es para asignar una pieza de datos a un nodo, podemos eliminar fácilmente esa dependencia de los componentes internos de un nodo de árbol simplemente devolviendo los datos que deberían asignarse al nodo en ese resumenQTInterface
.Entonces, si quiero desacoplar algo, solo me concentro en lo que necesita para hacer las cosas y encuentro una interfaz para ello (ya sea explícita usando la herencia o implícita usando el polimorfismo estático y la escritura de pato). Y ya lo hizo, en cierta medida, desde el propio árbol de árbol usando
QTInterface
para permitir que el cliente anule sus funciones con un subtipo y proporcione los detalles concretos necesarios para que el árbol de árbol haga lo suyo. El único lugar donde creo que te quedaste corto es que el cliente aún requiere acceso a las partes internas del quadtree. Puede evitar eso aumentando loQTInterface
que hace, que es precisamente lo que sugerí cuando hice que devolviera un valor para asignarnode.xxx
en la implementación quadtree en sí. Por lo tanto, solo se trata de hacer las cosas más abstractas y las interfaces más completas para que las cosas no requieran información innecesaria el uno del otro.Y al evitar esa información innecesaria (
Terrain
tener que saber sobre lasQuadtree
partes internas de los nodos), ahora es más libre de intercambiarQuadtree
con una implementación de GPU, por ejemplo, sin cambiar laTerrain
implementación también. Lo que las cosas no se conocen es libre de cambiar sin afectarse. Si realmente desea cambiar las implementaciones de GPU quadtree de las CPU, puede ir un poco hacia la ruta SÓLIDA anterior conIQuadtree
(haciendo que el quadtree sea abstracto). Eso viene con un golpe de despacho dinámico que puede ser un poco costoso con la profundidad del árbol y los tamaños de entrada de los que está hablando. Si no, al menos requiere muchos menos cambios en el código si las cosas que usan el quadtree no tienen que saber acerca de su representación de nodo interno para funcionar. Es posible que pueda intercambiar uno con el otro simplemente actualizando una sola línea de código paratypedef
, por ejemplo, incluso si no utiliza una interfaz abstracta (IQuadtree
).No necesariamente. El desacoplamiento a menudo implica desplazar una dependencia desde lo concreto a lo abstracto. Las abstracciones tienden a implicar una penalización en tiempo de ejecución a menos que el compilador esté generando código en tiempo de compilación para eliminar básicamente el costo de abstracción en tiempo de ejecución. A cambio, obtienes mucho más espacio para respirar para hacer cambios sin afectar otras cosas, pero eso a menudo extrae algún tipo de penalización de rendimiento a menos que estés usando la generación de código.
Ahora puede eliminar la necesidad de una estructura de datos asociativa no trivial (mapa / diccionario, es decir) para asociar datos a nodos (o cualquier otra cosa) sobre la marcha. En el caso anterior, acabo de hacer que los nodos almacenen directamente un índice de los datos que se asignan / liberan sobre la marcha. Hacer este tipo de cosas no está tan relacionado con el estudio de cómo desacoplar las cosas de manera efectiva sino con la manera de utilizar los diseños de memoria para las estructuras de datos de manera efectiva (más en el ámbito de la optimización pura).
Los principios y el desempeño de SE efectivos están en desacuerdo entre sí a niveles suficientemente bajos. A menudo, el desacoplamiento dividirá los diseños de memoria para los campos a los que se accede comúnmente juntos, puede implicar más asignaciones de almacenamiento dinámico, puede implicar un despacho más dinámico, etc. Se trivializa rápidamente a medida que trabaja hacia un código de nivel superior (por ejemplo: operaciones que se aplican a imágenes completas, no por -operaciones de píxeles cuando se realiza un bucle a través de píxeles individuales), pero tiene un costo que varía de trivial a severo dependiendo de cuánto se incurre en esos costos en su código de bucle más crítico que realiza el trabajo más ligero en cada iteración.
Personalmente, no creo que sea tan malo si no está tratando de generalizar demasiado su estructura de datos, solo usándola en un contexto muy limitado, y está lidiando con un contexto extremadamente crítico para un tipo de problema que no tiene Abordado antes. En ese caso, convertiría su quadtree en un detalle de implementación de su terreno, por ejemplo, en lugar de algo que se use ampliamente y públicamente, de manera similar, alguien podría convertir un octree en un detalle de implementación de su motor de física al no distinguir más idea de "interfaz pública" de "elementos internos". Mantener invariantes relacionados con el índice espacial se convierte en una responsabilidad de la clase que lo usa como un detalle de implementación privado.
Diseñar una abstracción efectiva (interfaz, es decir) en un contexto crítico para el rendimiento a menudo requiere que comprenda a fondo la mayor parte del problema y una solución muy efectiva por adelantado. En realidad, puede convertirse en una medida contraproducente para tratar de generalizar y abstraer la solución al mismo tiempo que intenta descubrir el diseño efectivo en múltiples iteraciones. Una de las razones es que los contextos críticos de rendimiento requieren representaciones de datos y patrones de acceso muy eficientes. Las abstracciones ponen una barrera entre el código que desea acceder a los datos: una barrera que es útil si desea que los datos sean libres de cambiar sin afectar dicho código, pero un obstáculo si está tratando simultáneamente de encontrar la forma más efectiva de representar y acceder a dichos datos en primer lugar.
Pero si lo hace de esta manera, nuevamente me equivocaría al convertir el quadtree en un detalle de implementación privado de sus terrenos, no algo que se generalice y use fuera de sus implementaciones. Y tendría que renunciar a la idea de poder cambiar tan fácilmente las implementaciones de GPU de las implementaciones de CPU, ya que eso normalmente requeriría una abstracción que funcione para ambos y que no dependa directamente de los detalles concretos (como las repeticiones de nodo) de cualquiera de las.
El punto de desacoplamiento
Pero tal vez en algunos casos esto incluso sea aceptable para cosas más utilizadas públicamente. Antes de que la gente piense que estoy diciendo tonterías locas, considere las interfaces de imagen. ¿Cuántos de ellos serían suficientes para un procesador de video que necesita aplicar filtros de imagen en el video en tiempo real si la imagen no expone sus elementos internos (acceso directo a su matriz subyacente de píxeles en un formato de píxel específico)? No hay ninguno que yo sepa sobre el uso de algo como un resumen / virtual
getPixel
aquí ysetPixel
allí mientras realiza conversiones de formato de píxel por píxel. Por lo tanto, en contextos suficientemente críticos para el rendimiento donde tiene que acceder a cosas en un nivel muy granular (por píxel, por nodo, etc.), a veces puede que tenga que exponer las partes internas de la estructura subyacente. Pero inevitablemente, como resultado, tendrá que acoplar las cosas estrechamente, y no será fácil cambiar la representación subyacente de las imágenes (cambio en el formato de la imagen, por ejemplo), por así decirlo, sin afectar todo el acceso a sus píxeles subyacentes. Pero podría haber menos razones para cambiar en ese caso, ya que en realidad podría ser más fácil estabilizar la representación de datos que la interfaz abstracta. Un procesador de video podría decidirse por la idea de usar formatos de píxeles RGBA de 32 bits y esa decisión de diseño podría ser inmutable en los años venideros.Idealmente, desea que las dependencias fluyan hacia la estabilidad (cosas que no cambian) porque cambiar algo que tiene muchas dependencias multiplica su costo con el número de dependencias. Eso puede o no ser abstracciones en todos los casos. Por supuesto, eso ignora los beneficios de ocultar información para mantener invariantes, pero desde el punto de vista del acoplamiento, el punto principal del desacoplamiento es hacer que las cosas sean menos costosas de cambiar. Eso significa redirigir las dependencias de cosas que podrían cambiar a cosas que no cambiarán, y eso no ayuda en lo más mínimo si sus interfaces abstractas son las partes que cambian más rápidamente de su estructura de datos.
Si desea al menos mejorar ligeramente desde una perspectiva de acoplamiento, separe las partes de su nodo a las que los clientes necesitan acceder lejos de las partes que no lo hacen. Supongo que los clientes al menos no tienen que actualizar los enlaces del nodo, por ejemplo, por lo que no hay necesidad de exponer los enlaces. Al menos, debería ser capaz de obtener un agregado de valor que esté separado de la totalidad de lo que los nodos representan para que los clientes puedan acceder / modificar, como
NodeValue
.fuente
Leyendo entre líneas, parece que estás demasiado concentrado en tu vista de árbol. Su idea parece ser "Tengo este árbol, con nodos, al que adjunto objetos y el árbol tiene que decirle a los objetos qué hacer". Sin embargo, debería ser al revés. Después de todo, el árbol es solo una vista que debe seguir sus objetos (dominio del problema). Los objetos no deben tener conocimiento / rastros del árbol (nodos). Es la vista la que lee los objetos y se presenta en consecuencia.
Es posible que desee implementar algunos eventos en sus objetos a los que el árbol pueda suscribirse, para que sepa cuándo colapsar, expandir, crear o eliminar un nodo.
Entonces, deja que el árbol siga tu modelo.
fuente