Después de darle un rápido pero productivo repaso a la documentación de "Cg Toolkit" en el artículo Cg "C for graphics" (I) aquí se presentan múltiples aspectos prácticos de su aplicación sobre la API OpenGL corriendo desde GLUT una de las "lanzaderas" de OpenGL más extendidas que existen.
Bueno, lo primero que deberíamos hacer si queremos practicar un poco con esto es saber con que herramientas contamos, en este caso que características tiene nuestro sistema y por tanto que perfiles vamos a poder utilizar. Si fuera una aplicación profesional lógicamente la pregunta a responder sería que perfiles soportan en general los sistemas que queremos cubrir pero en este caso, con que me vaya a mí me basta.
Si recordamos algo del tutorial, sabremos que podemos obtener un nombre de un perfil por su enumeración o bien la enumeración por el nombre del perfil. Desafortunadamente no hemos visto que haya ninguna función que nos de la lista de perfiles disponibles pero, alparceando un poco por las cabeceras de la API Cg concretamente en el archivo cg.h descubrimos dos reveladores elementos en la enumeración CGprofile que son CG_PROFILE_START y CG_PROFILE_MAX con lo que se nos hace fácil escribir la siguiente función:
// Listará los perfiles reconocidos y los soportados: static void ShowProfiles( void ) { printf("Cg Profiles:\n"); for( int n = (int) CG_PROFILE_START; n < (int) CG_PROFILE_MAX; n++ ) { CGprofile p = (CGprofile) n; const char *np = cgGetProfileString( p ); // Si devuelve NULL es que no está reconocido: if( np != NULL ) printf( "\t%s, %s soportado\n", np, cgGLIsProfileSupported( p ) ? "si" : "no" ); } }
La salida en mi caso resulta:
Cg Profiles: vp20, si soportado fp20, si soportado vp30, si soportado fp30, si soportado arbvp1, si soportado fp40, si soportado vs_1_1, no soportado vs_2_0, no soportado vs_2_x, no soportado ps_1_1, no soportado ps_1_2, no soportado ps_1_3, no soportado ps_2_0, no soportado ps_2_x, no soportado arbfp1, si soportado vp40, si soportado generic, no soportado
Pues ahora sólo resulta ver que ventajas puede resultar utilizar uno y otro (de los soportados, obviamente). Revisamos entonces cada uno de ellos en el manual y o mucho nos equivocamos, o los de la v son para VS y los de f son para PS:
Bien, como cabía esperar, lo recomendable va a ser usar vp40 y fp40 que, aunque no tienen la ventaja de los arb no pasa nada, es más, nos forzará a usar las funciones de parámetros (uniformes) específicamente.
Lógicamente en una aplicación que no sea un mero experimento encapsularemos las funcionalidades y tendremos que realizar muchas tareas que no están relacionadas (al menos directamente) con nuestros shaders. Por eso daremos un repaso a todos los elementos que actúan sin preocuparnos en cómo deberían estar ensamblados (para eso si lo crees necesario mirate el código fuente).
Para controlar nuestros shadersa necesitamos una serie de variables a saber:
// Acceso a un contexto del Cg Runtime: CGcontext cg_context; // Cada uno de los perfiles que usaremos para cada tipo de programa (vertex o fragment): CGprofile cg_v_profile, cg_f_profile; // Cada uno de nuestros programas: CGprogram cg_v_program, cg_f_program; // Todos y cada uno de los parametros "uniformes" (uniforms) de nuestros programas: CGparameter cg_v_p_ModelViewMatrix; CGparameter cg_f_p_globalAmbient; CGparameter cg_f_p_light0Color; CGparameter cg_f_p_light0Position; CGparameter cg_f_p_light1Color; CGparameter cg_f_p_light1Position; CGparameter cg_f_p_light2Color; CGparameter cg_f_p_light2Position; CGparameter cg_f_p_eyePosition; CGparameter cg_f_p_Ka; CGparameter cg_f_p_Kd; CGparameter cg_f_p_Ks; CGparameter cg_f_p_shininess; CGparameter cg_f_p_rugo_int; CGparameter cg_f_p_rugo_amp; CGparameter cg_f_p_banda;
Para entender cómo las variables interactúan con los programas definamos dos básicos que realmente no hacen nada:
El vertex program (p.e. en el archivo vp000.cg):
// Entrada principal al vertex shader void VertexProgram( // La GPU entregará vértice a vértice float4 position : POSITION, // Y nosotros sacaremos también vértice a vértice out float4 oPosition : POSITION, // Sin embargo, para todos los vértices, la matriz // de transformación es la misma uniform float4x4 ModelViewMatrix ) { // Símplemente obtenemos la posición en el espacio // de coordenadas del objeto en cuestión: oPosition = mul( ModelViewMatrix, position ); }
El fragment program (p.e. en el archivo fp000.cg):
void FragmentProgram( // La GPU entregará píxel a píxel float4 position : POSITION, // Y nosotros haremos lo propio out float4 color : COLOR, // Sin embargo, para todos los píxels, las variables // uniformes son las mismas uniform float3 globalAmbient ) { color.xyz = globalAmbient; color.w = 1; }
Para inicializar el sistema Cg lo suyo es crear un contexto y además con uno nos debería de bastar:
// Contexto Cg cg_context = cgCreateContext();
Para saber los perfiles a utilizar, o bien seleccionamos unos que nosotros sepamos que existan o bien buscamos aquellos que tienen la última versión, así si sabemos que nuestro programa funciona a partir de determinadas versiones siempre actuará con el mejor perfil. De paso, estableceremos las opciones óptimas para cada perfil:
// Perfil para el vertex program cg_v_profile = cgGLGetLatestProfile( CG_GL_VERTEX ); // Establecemos las mejores opciones predeterminadas: cgGLSetOptimalOptions( cg_v_profile ); // Perfil para el fragment program cg_f_profile = cgGLGetLatestProfile( CG_GL_FRAGMENT ); // Establecemos las mejores opciones predeterminadas: cgGLSetOptimalOptions( cg_f_profile );
Crear cada uno de los programas es igual de sencillo, así, ponemos los dos directamente y asociamos los parámetros uniformes:
// Creamos el vertex program cg_v_program = cgCreateProgramFromFile( cg_context, CG_SOURCE, "vp000.cg", cg_v_profile, "VertexProgram", 0 ); // Lo cargamos: cgGLLoadProgram( cg_v_program ); // Asociamos sus parámetros (aquí sólo uno): cg_v_p_ModelViewMatrix = cgGetNamedParameter( cg_v_program, "ModelViewMatrix" ); // Creamos el fragment program cg_f_program = cgCreateProgramFromFile( cg_context, CG_SOURCE, "fp000.cg", cg_f_profile, "FragmentProgram", 0 ); // Lo cargamos: cgGLLoadProgram( cg_f_program ); // Asociamos sus parámetros (aquí sólo uno): cg_f_p_globalAmbient = cgGetNamedParameter( cg_f_program, "globalAmbient" );
Pues realmente no hay nada más que hacer, hemos inicializado el contexto Cg, hemos buscado los perfiles y establecido los parámetros por defecto, hemos cargado y compilado los programas y establecidas las referencias a sus parámetros para poder asignarlos después. Todo preparado.
Ahora queda utilizar los programas. Esto se hace al renderizar "algo", es decir, cuando enviamos primitivas de renderizado estilo GL_TRIANGLES, GL_QUADS u otras por el estilo. Las explicaciones en el mismo código:
// Nuestra función de dibujar la escena: void DisplayFunc( void ) { ... // Cosas como: glClear( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT ); glMatrixMode( GL_MODELVIEW ); glLoadIdentity(); gluLookAt( ... // Al querer "activar" nuestros shaders: // Le decimos a Cg que use nuestro vertex program: cgGLBindProgram( cg_v_program ); // Le decimos que actualice la matriz a nuestro parámetro uniforme. // Esta es una manera especial de establecer el valor en nuestro // parámetro uniforme, podríamos haber usado cgSetMatrixParameterfr. cgGLSetStateMatrixParameter( cg_v_p_ModelViewMatrix, CG_GL_MODELVIEW_PROJECTION_MATRIX, CG_GL_MATRIX_IDENTITY ); // Aseguramos que está activo este perfil: cgGLEnableProfile( cg_v_profile ); // Ahora el fragment program: cgGLBindProgram( cg_f_program ); // Establecer un parámetro "normal" es bien fácil: cgSetParameter3fv( cg_f_p_globalAmbient, colorAmbiente ); // Y por supuesto debemos activar su respectivo perfil: cgGLEnableProfile( cg_f_profile ); // Ahora sólo queda dibujar nuestro mesh, como por ejemplo: glCallList( mi_objeto ); // Y pos si las moscas, deshabilitamos los perfiles para que en // otras renderizaciones (de quizás otras primitivas) no se // ejecuten nuestros shaders: cgGLDisableProfile( cg_f_profile ); cgGLDisableProfile( cg_v_profile ); ... }
Desde luego no se puede decir que sea complicado. ¡Ya está!. Claro que con semejantes programas habremos obtenido algo como esto:
Al vertex program le llegan los vértices en bruto, tal y como han sido entregados desde las funciones glVertex...() u otras (p.e. si se usan arrays de vértices), esto quiere decir que, en contra de lo que pueda pensarse, el siguiente código para generar la lista mi_objeto de antes, no hace lo que se espera:
glNewList( objeto, GL_COMPILE ); glShadeModel( GL_SMOOTH ); glutSolidTorus( 2.0, 6.0, 30, 60 ); glTranslatef( 0.0f, 2.0f, 0.0f ); Sphere( 3.0, 2 ); glEndList();
No, la esfera no será trasladada respecto del toroide. Dicho de otra forma, cualquier variación de la matriz GL_MODEL_VIEW no tendrá ningún efecto sobre los vértices que son entregados a nuestro vertex program.
Entonces, ¿cómo actúan las transformaciones?.
Bien, como habrás visto en el ejemplo de antes, al VP se le pasa un parámetro uniforme que es precisamente la matriz de transformación a usar, puesto que el VP decide en todo momento como se transforma cada vértice. Es posible aun así utilizar las transformaciones típicas de OpenGL pero deberemos recoger la matriz de transformación y pasarla al Cg (o utilizar las funciones para ello como la del ejemplo anterior cgGLSetStateMatrixParameter).
Trivialidades que puede hacer un vertex program con los vértices son moverlos en el espacio de objeto (sin aplicar la matriz), moverlos en el espacio global (aplicando la matriz), producir turbulencias en función de su posición, etc... aunque una de las aplicaciones típicas es transformar cada uno de los "huesos" móviles de diferentes objetos animados. El ejemplo de NVidia llamado "Improved Skinning" es genial, y de una forma muy sencilla permite que la GPU realice todas las transformaciones de un cuerpo animado con hasta 30 matrices de transformación y hasta 4 huesos actuando simultáneamente sobre cada vértice. El shader resultante es sencillo y muy potente (todas las multiplicaciones de transformaciones de vértices que deben hacerse las hace la GPU y no la CPU).
// La entrada la definen como: struct inputs { // La posición y normal del vértice a transformar: float4 position : POSITION; float4 normal : NORMAL; // El peso que cada "hueso" tiene sobre el vértice, // esto es muy interesante pues un vértice puede verse // afectado por el movimiento de dos huesos y cada hueso // "tirará" hacia él: float4 weights : BLENDWEIGHT; // Los índices de las matrices de transformación de // cada hueso: float4 matrixIndices : TESSFACTOR; // Sólo se usa X para indicar el número de huesos // de este vértice (máximo 4): float4 numBones : SPECULAR; }; // La salida del programa será: struct outputs { // Cada posición y color, aunque lo suyo es que // sean sólo la posición y la normal (el color // se modifique en el FP): float4 hPosition : POSITION; float4 color : COLOR0; }; // Programa principal: outputs main( // Vértice a transformar: inputs IN, // La matriz de vista para pasar a globales: uniform float4x4 modelViewProj, // Las matrices de transformación de huesos: uniform float3x4 boneMatrices[30], // El color: uniform float4 color, // La posición de la luz (la calculan aquí): uniform float4 lightPos ) { outputs OUT; float4 index = IN.matrixIndices; float4 weight = IN.weights; float4 position; float3 normal; // Para cada hueso: for( float i = 0; i < IN.numBones.x; i += 1 ) { // Se pondera la posición transformada: position = position + weight.x * float4( mul( boneMatrices[index.x], IN.position ).xyz, 1.0); // Se pondera la normal transformada: normal = normal + weight.x * mul( (float3x3) boneMatrices[index.x], IN.normal.xyz ).xyz; // Se pasan las coordenadas hacia la X rotando el hueso que actúa: index = index.yzwx; weight = weight.yzwx; } // Lo demás es trivial: normal = normalize(normal); OUT.hPosition = mul(modelViewProj, position); // Modelo tosco de iluminación: OUT.color = dot(normal, lightPos.xyz) * color; return OUT; }
En definitiva, el vertex program realizará todas las tareas que deban realizarse a nivel de vértice. Calcular la iluminación en una escena puede hacerse a nivel de vértice (como en el ejemplo anterior) en cuyo caso se interpolará la luminosidad para la superfície en la que forme parte (un triángulo por ejemplo). Esto tiene la ventaja de calcularse sólo para cada vértice (en una escena hay muchos menos vértices que píxeles) aunque lógicamente la calidad es menor.
Una vez transformados los vértices y formadas las primitivas a renderizar (que finalmente serán triángulos) queda rellenar el color de cada píxel que forma el triángulo final en la pantalla. Cada uno de estos píxeles son los que entran en el fragment program, esto nos permite controlar cómo se aplica la textura (color), la luminosidad, la transparencia e incluso la profundidad en el Z-buffer.
Para aplicar correctamente y "refinadamente" la apariencia de los objetos lo mejor es, sin ninguna duda, usar el fragment shader puesto que nos permite establecerlo para cada píxel, sin embargo y como es lógico, el coste computacional es mucho más alto y debería llevarse la mayor cantidad de trabajo posible al vertex program (p.e. actuaciones sobre la normal que puedan interpolarse después).
Una de las aplicaciones típicas del FP es calcular la luz para cada píxel en la escena, la única "dificultad" reside en entregar al FP las coordenadas adecuadas de la cámara y de la luz, pero esto es fácil si se transforman adecuadamente con la matriz actual en cada caso (justo antes de renderizar el objeto). Atentos a la jugada:
// Justo después de establecer la matriz de proyección de la cámara (p.e. llamando a gluLookAt) // guardamos la matriz (para poder "restar" esta transformación) ... float basematrix[16], ibasematrix[16]; glGetFloatv( GL_MODELVIEW_MATRIX, ibasematrix ); transposeMatrix( basematrix, ibasematrix ); // matriz base invertMatrix( ibasematrix, basematrix ); // matriz base invertida // Ahora tenemos en ibasematrix una matriz que nos "elimina" la transformación realizada // por la cámara (queremos las coordenadas en globales, no locales al objeto o a la cámara). ... // Aquí podemos hacer todas las transformaciones que sean precisas, glRotate, glTranslate, ... ... // Al dibujar el objeto, obtenemos la matriz de todas las transformaciones, le quitamos // la que teníamos guardada y listo, tenemos la transformación para pasar de locales a // globales en immatrix: float mmatrix[16], immatrix[16]; glGetFloatv( GL_MODELVIEW_MATRIX, mmatrix ); transposeMatrix( immatrix, mmatrix ); multMatrix( mmatrix, ibasematrix, immatrix ); // transformación cámara a espacio objeto invertMatrix( immatrix, mmatrix ); // inversa espacio objeto a cámara // Notar que ahora ya no podemos (bueno, dará igual) usar transformaciones estilo glRotate y // otras, de querer hacer algo, deberíamos meterlo en immatrix // Al llamar al fragment program calculamos las posiciones globales de luces y cámara transformPosition( cg_camara_pos, immatrix, camara_pos ); for( j = 0; j < NUM_LIGHTS; j++ ) transformPosition( cg_light[j], immatrix, light[j] ); // Listo, ya podemos meter esto en los parámetros: cgSetParameter3fv( cg_f_p_eyePosition, cg_camara_pos ); for( j = 0; j < NUM_LIGHTS; j++ ) cgSetParameter3fv( cg_f_p_lightPosition[j], cg_light[j] ); ...
Calcular la luz es muy fácil, aunque en el ejemplo que ponen en el manual de NVidia lo complican un poco, tienes aquí mi versión, que no es más que aplicar directamente las fórmulas pertinentes, la ventaja de ponerlo así, es que nos sirve tanto para el FP como para el VP (según queramos calcular la luz por vértice o por píxel):
float3 ComputeLightColor( float3 P, float3 N, float3 lightPosition, float3 lightColor, float3 eyePosition, float Kd, float Ks, float shininess ) { // Vector director del rayo de luz float3 L = normalize( lightPosition - P ); // Intensidad de la luz que llega al punto (sin decail) float ilight = dot( L, N ); // ¿Hay luz? if( ilight <= 0 ) return float3( 0, 0, 0 ); return lightColor * ( // Componente difusa Kd * ilight + // Componente especular Ks * pow( max( dot( normalize( L + normalize( eyePosition - P ) ), N ), 0 ), shininess ) ); }
La modificación en el FP no es gran cosa, lógicamente debemos poner los parámetros uniformes que nos dan información de cada luz (posición y propiedades), el material, posición de la cámara, etc...
void FragmentProgram( float4 position : TEXCOORD0, float3 normal : TEXCOORD1, // Este nos sirve para saber la cara oculta, si llegamos a este punto, // es que se quiere rasterizar las caras traseras de los polígonos y // éstos, también hay que iluminarlos, pero hará que la normal tengamos // que negarla (ver adelante). float cara : FACE, out float4 color : COLOR, uniform float3 globalAmbient, uniform float3 light0Color, uniform float3 light0Position, uniform float3 light1Color, uniform float3 light1Position, uniform float3 light2Color, uniform float3 light2Position, uniform float3 eyePosition, uniform float3 Ka, uniform float3 Kd, uniform float3 Ks, uniform float shininess, uniform float rugo_int, uniform float rugo_amp, uniform float banda) { float3 P = position.xyz; // al multiplicar por 'cara' conseguimos que la normal se adapte a // la cara frontal o trasera: float3 N = cara * normalize( normal ); float3 fcolor = Ka * globalAmbient; // Fácil ¿no? fcolor += ComputeLightColor( P, N, light0Position, light0Color, eyePosition, Kd, Ks, shininess ); fcolor += ComputeLightColor( P, N, light1Position, light1Color, eyePosition, Kd, Ks, shininess ); fcolor += ComputeLightColor( P, N, light2Position, light2Color, eyePosition, Kd, Ks, shininess ); color.xyz = fcolor; color.w = 1; }
Bien, en este caso hemos puesto tres luces, blanca, roja y azul y el resultado es bastante bueno:
Por supuesto podemos calcular el color de textura a partir de texturas "tradicionales" pero también es posible calcular el color con otros métodos, en el siguiente ejemplo símplemente establecemos un color u otro según la distancia al punto entero más cercano, además, suavizamos fácilmente (sin utilizar antialiasing) la transición entre las bandas "superpuestas" y el color "real" del objeto:
// Esto en el FP: ... float Q = banda * ( distance( float3( P.x, P.y * 0, P.z ), float3( 0, 0, 0 ) ) + 0.1 ); Q = distance( Q, trunc(Q) ); if( 0.2 < Q && Q < 0.8 ) { float t; if( Q < 0.4 ) t = 1 - ( Q - 0.2 ) / 0.2; else if( Q > 0.6 ) t = ( Q - 0.6 ) / 0.2; else t = 0.0; float q = ( 1 - t ) * 0.3333333; Ka = Ka * t + q * ( Ka.rrr + Ka.ggg + Ka.bbb ); Ks = Ks * t + q * ( Ks.rrr + Ks.ggg + Ks.bbb ); Kd = Kd * t + q * ( Kd.rrr + Kd.ggg + Kd.bbb ); }
Y yo creo que, ya para terminar, podemos modificar también para cada píxel la normal de la superfície con la que se calcula la luz, por ejemplo aplicando una pequeña turbulencia en función de la posición del píxel en el espacio:
// Esto en el FP: ... // rotamos la normal a lo largo del eje XY, YZ y ZX float fc, fs; float3x3 noise; sincos( rugo_int * cos( rugo_amp * P.z ), fs, fc ); noise = float3x3( fc, -fs, 0, fs, fc, 0, 0, 0, 1 ); sincos( rugo_int * cos( rugo_amp * P.x ), fs, fc ); noise = mul( noise, float3x3( 1, 0, 0, 0, fc, -fs, 0, fs, fc ) ); sincos( rugo_int * cos( rugo_amp * P.y ), fs, fc ); noise = mul( noise, float3x3( fc, 0, -fs, 0, 1, 0, fs, 0, fc ) ); // Aplicamos la matriz de rotaciones a nuestra normal: N = mul( N, noise );
Para que se vea mejor el efecto, en lugar de una imagen estática pongo esta diminuta animación:
Pulsa para ver el vídeo (1Mbyte)