El trastero de José Juan Valid XHTML 1.1 Valid CSS! Estilo de página alternativo
Artículo creado en 2008.
Valoración ValoraciónValoraciónValoraciónValoraciónValoraciónValoraciónValoraciónValoraciónValoraciónValoración sobre 1 comentarios.

Cg "C for graphics" (II)

Introducción

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.

Descubriendo perfiles

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.

Variables para nuestros programas

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;

Dos programas shaders básicos

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

Inicializando el sistema Cg

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 );

Cargando y compilando programas

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.

Utilizando los programas

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:

Screenshot 0

Entendiendo el espacio del vertex program

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).

Deformando modelos con huesos

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.

El fragment program

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:

Screenshot 1

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 );
	}

Screenshot 2

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:

Screenshot vídeo
Pulsa para ver el vídeo (1Mbyte)




Opinado el 11/04/11 06:24, valoración ValoraciónValoraciónValoraciónValoraciónValoraciónValoraciónValoraciónValoraciónValoraciónValoración
    haz paro pon todo el codigo para correrlo !!!!!!!!
¿Te ha gustado? ¡aporta tu opinión!
Valoración:
 0    1    2    3    4    5    6    7    8    9    10

Comentario:
NOTA: si es una petición... ¡pon el e-mail al que responderte o no sabré a dónde escribir!

Código de verificación captcha