Como criar jogos 3D com o PureScript Native e C ++

Lettier Blocked Unblock Seguir Seguindo 17 de dezembro

Quando escrevi pela última vez sobre a vinculação do PureScript ao C ++ , demonstrei a animação do logotipo do PureScript com o SFML. Desde então, o PureScript Native (PSN) substituiu o Pure11 e os detalhes para o uso da PSN foram alterados.

Para tornar isso concreto, vou repassar o que foi preciso para criar o Lambda Lantern – um jogo 3D sobre padrões de programação funcional. Originalmente Lambda Lantern começou como uma submissão do GitHub Game Off . Como você só tem 30 ou mais dias para terminar, alguns atalhos tiveram que ser feitos. Eu pretendo abordar os atalhos e realmente detalhar o jogo usando-o como um veículo para conduzir a ligação de C ++ e o ecossistema ao redor dele.

? Se você não quer criar um jogo, mas ainda gostaria de ligar o PureScript ao código C ++ ou talvez esteja procurando uma alternativa para vincular o Haskell ao C ++ – não se preocupe – tentei manter este guia tão geral quanto possível.

Configurando o Projeto

Git clone PSN e construa-o com pilha como você faria com qualquer outro projeto Haskell Stack.

 git clone https://github.com/andyarvanitis/purescript-native 
cd nativo nativo
instalar pilha
CD

Quando terminar, você terminará com o compilador PureScript para C ++ chamado pscpp . Se você empilhar instalado, ele deve estar no diretório bin local. Em qualquer caso, certifique-se de que sua variável de ambiente path tenha o caminho para pscpp .

 export PATH = "$ {PATH}: $ {HOME} /. local / bin" 

Em seguida, você precisará do Node.js e do NPM. Eu gosto de gerenciar o nó com NVM

 git clone https://github.com/lettier/lambda-lantern.git 
cd lambda-lanterna /
nvm install `cat .nvmrc` && nvm use

mas sinta-se livre para usar um método que você goste. Instale purescript e psc-package como assim.

 npm instalar -g purescript psc-package-bin-simple 

A PSN pode gerar um Makefile conveniente que gerencia o processo de criação. Se o Makefile ainda não estiver presente, vá em frente e gere isso agora.

 pscpp --makefile 

O Lambda Lantern já tem um pacote para o psc-package . Eu não podia usar o mais comum, pois precisava incluir o fork do prelúdio de Andy Arvanitis. O fork não depende das comparações de igualdade inseguras e implícitas. Há um PR aberto para isso, então espero que seja mesclado em breve. De qualquer forma, você sempre pode desenvolver seu próprio conjunto de pacotes com o psc-package init e apontar o arquivo psc-package.json para o seu repositório local ou remoto contendo seu arquivo packages.json .

Depois de ter seu arquivo psc-package.json pronto, instale as dependências do PureScript com o seguinte comando.

 instalação do pacote psc 

Se você olhar em .psc-package/ verá todo o código do PureScript para o seu conjunto de pacotes, mas ainda está faltando o C ++ FFI (interface de função externa).

Crie um diretório ffi/ no diretório principal do seu projeto. O Makefile que você gerou anteriormente está configurado para procurar lá. Você pode, claro, alterá-lo, mas certifique-se de atualizar o Makefile .

Clone o conteúdo de purescript-native-ffi no diretório ffi/ .

?? Para Lambda Lantern , ao clonar o repositório FFI, tenha cuidado para não sobrescrever quaisquer arquivos existentes, pois eu tive que fazer algumas modificações e adições.

 clone git  
https://github.com/andyarvanitis/purescript-native-ffi.git
cp -nr purescript-native-ffi /. lambda-lanterna / ffi /

O diretório do seu projeto deve ser parecido com isto.

 / 
.psc-package /
ffi /
node_modules /
src /
Main.purs
Makefile
psc-package.json

Neste ponto, você desejará instalar o mecanismo do jogo ou o código C ++ ao qual será vinculado. Para o Lambda Lantern , você precisará instalar o Panda3D . O Panda3D possui instaladores para Linux, macOS e Windows.

Escolhendo o motor do jogo

Depois de analisar alguns dos mecanismos de jogos baseados em C ++, decidi pelo Panda3D, pois ele tinha a API mais direta a ser usada. O Panda3D vem com a maioria das funcionalidades que você vê em outros lugares, como shaders, efeitos de partículas, física, múltiplos alvos de renderização, áudio 3D, etc. Ainda assim, se você preferir outro mecanismo, sinta-se à vontade para usá-lo. A ideia básica para este guia ainda é aplicável.

Um padrão comum que notei em muitos dos mecanismos que analisei foi a necessidade de herdar de alguma classe de aplicativo. O motor do jogo controla o principal e chama o seu estilo de princípio de classe de aplicação preenchido de Hollywood. Ter o controle do motor principal criaria um sanduíche C ++ e PureScript inábil – iniciando e terminando em C ++ com a lógica do jogo PureScript no meio. Você poderia, tenho certeza, desconstruir a classe de aplicativos e replicar o que o mecanismo está fazendo no main, mas com o Panda3D disponível, não vi a necessidade.

A documentação do Panda3D é extensa, mas às vezes eu tive que mergulhar no código para encontrar essa ou aquela classe que não apareceu na referência do C ++ . Os fóruns também são um ótimo recurso com mais de uma década de posts para pesquisar. Às vezes você se deparar com soluções desatualizadas, então recomendo classificar por recente.

Criando as exportações FFI do C ++

Aqui está um arquivo PSN C ++ FFI.

 #include "purescript.h" 
 FOREIGN_BEGIN (SomeModuleName) 
FOREIGN_END

Entre o início e o fim estrangeiros é onde você define as exportações que são chamadas do lado do PureScript.

 exportações ["id"] = [] (const box & param_) -> boxed { 
return param_;
};

Para desviar de parâmetros e valores de retorno, a PSN usa a classe na boxed . Esta classe na boxed construída a partir do shared_ptr .

Para trabalhar com um valor in a boxed , você terá que unbox lo, fornecendo seu tipo.

 auto param = unbox <type> (param_); 

?? Se você não sabe o tipo, então você está preso. Esse é o problema com as comparações de igualdade de tipo implícito, já que os valores da boxed têm seus tipos apagados.

? Todas as exportações devem aceitar valores em boxed e retornar valores em boxed . Alguns tipos como string , char , double , int , long , etc. podem ser retornados diretamente. O compilador irá construir um valor em boxed para você, já que a classe boxed alguns construtores convenientes.

 exportações ["echo"] = [] (const boxed & s_) -> boxed { 
const auto s = unbox <string> (s_);
return s;
};

Para outros tipos, você vai ter que chamar a box procedimento para a caixa acima de seu tipo.

 Exportações ["someFunction"] = [] (const boxed & param_) -> boxed { 
auto param = unbox <Type> (param_);
// ...
caixa de retorno <Type> (param);
};

Você precisará curry suas funções ou, em outras palavras, retornar uma nova função lambda para cada parâmetro. Se a sua função retornar um Effect , você precisará de uma função lambda adicional que não tenha parâmetros.

No PureScript:

 importação estrangeira add1 :: Number -> Number 
 importação estrangeira add1 ':: Number -> Número do efeito 

Em C ++:

 exportações ["add1"] = [] (const boxed & n_) -> boxed { 
const auto n = unbox <duplo> (n_);
return n + 1,0;
};
exportações ["add1 '"] = [] (const boxed & n_) -> boxed {
const auto n = unbox <duplo> (n_);
return [=] () -> caixa {
return n + 1,0;
};
};

Marcar alguma importação com o Effect depende de você, mas geralmente, se a função alterar alguma coisa fora de seu escopo ou retornar uma saída diferente para a mesma entrada, ela deve usar Effect .

Para aqueles que não estão familiarizados com a sintaxe acima, as exportações da PSN usam as funções lambda do C ++.

 [] // significa não capture nenhuma variável externa. 
[=] // significa capturar variáveis externas por valor.
[&] // significa capturar variáveis externas por referência.

Por valor significa fazer uma cópia duplicada do mesmo, de modo que qualquer edição da cópia não afetará o original e, por meio de referência, criará um alias tal que qualquer edição afetará o original. Idealmente, você vai querer usar [=] vez de [&] para não alterar os parâmetros de entrada.

Aqui estão alguns padrões típicos de função lambda C ++.

 [& capture, = list] (p, a, r, a, m, s) -> ReturnType {corpo; } 
[& captura, = lista] (p, a, r, a, m, s) {corpo; }
[& capture, = list] {corpo; }

Acredito que a melhor prática é unbox seus parâmetros conforme você os recebe e usar [=] para cada função lambda retornada como essa.

 [] (const boxed & param_) -> boxed { 
const auto param = unbox <tipo> (param_);
 return [=] (const box & param1_) -> boxed { 
const auto param1 = unbox <tipo> (param1_);
 return [=] (const boxed & param2_) -> boxed { 
const auto param2 = unbox <tipo> (param2_);
 Retorna ... 
};
};
};

No entanto, para certas exportações, tive que unbox os parâmetros apenas na última função lambda. Por exemplo, como este . Unboxing no primeiro lambda e depois copiar por referência como isso causou um segfault. E eu não podia unbox no primeiro lambda e copie por valor como este como o compilador iria reclamar que eu estava tentando mudar uma constante NodePath com set_scale . Havia a opção de usar a palavra-chave mutável como este , mas unboxing todos os parâmetros na última função lambda funcionou bem.

Outra rota seria alocar dinamicamente caminhos de nó –

 // ... 
return [&] () -> em caixa {
// ...
const auto nodePathPtr =
std :: make_shared <NodePath> (
nodePath.find (consulta)
);
return nodePathPtr;
};
// ...

retornando e aceitando ponteiros para eles – mas isso teria adicionado sobrecarga desnecessária. Ainda assim, com ponteiros, você pode mantê-los constantes, descompactá-los assim que recebê-los e copiá-los por valor usando [=] .

Geralmente, eu fiquei perto da API do Panda3D. Idealmente, você deseja que suas exportações sejam muito pequenas e básicas – colocando qualquer lógica extra no lado do PureScript para proteção adicional. No entanto, em alguns casos, desviava quando os procedimentos eram rotineiros e repetitivos.

Vincular-se a algumas APIs do Panda3D não era o único FFI necessário. Havia também a necessidade de criar exportações de FFI para funcionalidades comuns como now , funções centrais do JS como setInterval , requestAnimationFrame , etc., e procurar variáveis de ambiente do sistema – algo completamente estranho ao JavaScript (pelo menos no navegador).

? A maior parte do ecossistema do PureScript pressupõe um backend JavaScript que é compreensível. Espero que, no entanto, o uso de PSN cresça – aumentando com isso a cobertura C ++ FFI. Ser capaz de segmentar facilmente a Web e as plataformas nativas com apenas PureScript é definitivamente ideal.

Uma chamada FFI específica foi para a window do navegador, o que não era aplicável, então eu a preenchai com uma não-op.

 exports ["window"] = [] () -> em caixa {return boxed (); }; 

?? Observe que o tempo de execução da PSN causará um erro se não conseguir encontrar a exportação da FFI para alguma chamada FFI. Se isso acontecer, você verá algo como o seguinte.

 encerrar chamado após lançar uma instância de 
'std :: runtime_error'
 what (): chave de dicionário "setInterval" não encontrada 

Obviamente, seria ótimo se isso fosse capturado em tempo de compilação, mas isso pode não ser viável.

Eu sabia que queria usar a programação reativa funcional (FRP), então eu precisava purescript-behaviors purescript-event . O primeiro usa requestAnimationFrame enquanto o posterior usa clearInterval e setInterval .

Contando os dias até o final do jogo, decidi usar um modelo multi-thread simples para emular essas funções centrais do JS em C ++. Lidando com vários encadeamentos versus a natureza de encadeamento único do JavaScript, houve algumas diferenças sutis e não tão sutis nos meus eventos do FRP.

No JavaScript isso

 setInterval 
(function () {while (true) {console.log (1);}}
1000
);
setInterval
(function () {while (true) {console.log (2);}}
1000
);

imprimiria sempre 1 vez após vez, nunca dando uma chance ao segundo intervalo. Mas em C ++ (usando minha FFI) algo parecido com isto

 setInterval 
([] {while (true) {std :: cout << 1 << " n";}}
1000
);
setInterval
([] {while (true) {std :: cout << 2 << " n";}}
1000
);

imprimiria 1 e 2 repetidamente ao mesmo tempo.

Eu pretendo retornar para o modelo multi-threaded e convertê-lo em um modelo de agendamento de fila prioritária, único e não preemptivo para ser mais parecido com sua contraparte JavaScript.

Criando as importações de FFI do PureScript

O lado do PureScript da cerca da FFI não foi tão cheio de eventos. Tendo mais tempo, eu teria criado typeclasses para emular as hierarquias de classes encontradas no Panda3D. Agora que o jogo acabou, posso passar e arrumar as interfaces abstraindo as semelhanças.

Criar as chamadas FFI do PureScript para C ++ é o mesmo que para JavaScript.

Aqui está um exemplo de chamada FFI para criar uma luz ambiente.

 dados de importação estrangeiros PandaNode :: Type 
 - ... 
 importação estrangeira createAmbientLight 
:: Corda
-> Número
-> Número
-> Número
-> Efeito PandaNode

Essa importação chamará sua contraparte de exportação C ++ FFI .

Olhando para o C ++ gerado pelo pscpp , você pode ver como este é eventualmente conectado.

 auto createAmbientLight () -> const boxed & { 
static const boxed _ =
foreign (). at ("createAmbientLight");
Retorna _;
};
 // ... 
 boxed v27 = 
Panda3d :: createAmbientLight
()
("luz ambiente")
(0,125)
(0,122)
(0,184)
();

Construindo o Projeto

? A PSN usa o compilador purs (v0.12.0), portanto, não há diferença – com relação ao PureScript – entre os destinos C ++ e JavaScript.

Aqui está o esboço geral para o processo de construção.

  • Compile o código do PureScript chamando purs e produzindo algo chamado “CoreFn”, que é a representação AST (árvore de sintaxe abstrata).
  • Compile o CoreFn chamando pscpp e produzindo C ++.
  • Compile e vincule o C ++ chamando algo como g++ e exibindo o executável final.

Usando o Makefile gerado torna o processo de construção super fácil. Ele permite que você passe as bandeiras do compilador e do vinculador, se necessário. Aqui está o comando make que eu uso para construir a Lambda Lantern

 faço  
CXXFLAGS = "
-fmax-errors = 1
-I / usr / include / python
-I / usr / include / panda3d
-I / usr / include / freetype2 "
LDFLAGS = "
-L / usr / lib / panda3d
-lp3framework
-landa
-landaanda
-lpandaexpress
-lp3dtoolconfig
-lp3dtool
-p3pystub
-lp3direct
-pthread
-lpthread "

A maioria das bandeiras deve incluir os cabeçalhos do Panda3D e o link contra as bibliotecas do Panda3D.

?? /usr/include/python , /usr/include/panda3d , /usr/lib/panda3d , etc. podem não ser os caminhos exatos para o seu sistema. Você também pode precisar de sinalizadores adicionais não listados acima. Por exemplo, no Ubuntu eu tenho que incluir -I/usr/include/eigen3 .

Depois de construir seu projeto, o executável estará localizado em ./output/bin/ .

Distribuindo o projeto

A PSN é uma plataforma cruzada entre o Linux, o macOS e o Windows. Para esta seção, falarei sobre o que fiz para distribuir o Lambda Lantern para Linux.

Tendo criado snaps, flatpaks, pacotes AUR e AppImages, acho que a AppImage é a melhor experiência tanto para o distribuidor quanto para o usuário.

Para começar, você vai querer descobrir em quais bibliotecas o seu binário depende.

 objdump -p output / bin / main 

Procure a “Seção Dinâmica” na saída.

 Seção Dinâmica: 
NECESSÁRIO libp3framework.so.1.10
NECESSÁRIO libpanda.so.1.10
...

?? Você pode precisar de bibliotecas adicionais não listadas. E mesmo se você mantiver todas as bibliotecas, seu AppImage pode não ser executado em distribuições mais antigas, dependendo de como o seu ambiente de construção é atualizado (geralmente devido à libc). Eu recomendo usar o Ubuntu 14.04 ou 16.04 como seu ambiente de compilação e, em seguida, testar seu AppImage em quantas instalações novas você puder. Se você perdeu alguma biblioteca, receberá um erro – informando qual biblioteca não pode ser encontrada – da seguinte forma.

 error while loading shared libraries: libpanda.so.1.10 : cannot open shared object file: No such file or directory 

O bom do AppImage é que você pode incluir quantas bibliotecas desejar. O ideal seria incluir qualquer biblioteca que não seja normalmente encontrada no sistema médio. Isso dará ao seu AppImage a maior chance de trabalhar fora da caixa.

Aqui está a árvore de diretórios do Lambda Lantern AppImage.

 / 
etc /
Confauto.prc
Config.prc
usr /
lib /
panda3d /
compartilhar/
aplicações /
com.lettier.lambda-lantern.desktop
ícones / hicolor / 256x256 /
com.lettier.lambda-lantern.png
lanterna lambda /
ativos/
ovos/
fontes /
música/
sons /
licenças /
metainfo /
com.lettier.lambda-lantern.appdata.xml
AppRun

Os arquivos Config.prc e Confauto.prc são necessários para que o Panda3D seja executado. No arquivo AppRun , forneci o caminho para os arquivos prc e para o diretório de ativos do Lambda Lantern. Se você se lembra, eu tive que criar uma exportação / importação FFI para procurar variáveis de ambiente. No início do programa, Lambda Lantern procura uma variável de ambiente para encontrar seus ativos.

 exportar LAMBDA_LANTERN_ASSETS_PATH =  
"$ {AQUI} / usr / share / lambda-lantern / assets"
export PANDA_PRC_DIR = "$ {AQUI} / etc"

Com todos os arquivos no lugar e o arquivo AppRun preenchido, você cria o AppImage assim.

 appimagetool-x86_64.AppImage lambda-lantern.AppDir 

A ferramenta AppImage executará alguma validação e, em seguida, gerará o AppImage se tudo sair.

Se você estiver usando o GitHub, poderá carregar o AppImage para uma versão para download dos usuários. Após o download, os usuários podem marcar o AppImage como executável e começar a jogar! ?

Pensamentos finais

Vincular o Haskell ao C é bastante fácil, mas não é assim com o C ++ . Isso é lamentável, já que algumas das bibliotecas amplamente usadas para desenvolvimento de jogos são tipicamente em C ++. Por algum tempo eu tentei vários métodos diferentes para vincular o Haskell ao C ++, mas nunca foi tão simples quanto vincular o PureScript ao C ++ usando o PSN. Se você encontrou uma ótima maneira de vincular o Haskell a projetos complexos em C ++, me avise. No entanto, se você estiver tentando ligar o Haskell ao C ++ – não encontrando ótimas opções – considere fortemente a PSN .

Ser capaz de ligar o PureScript ao C ++ abre tantas possibilidades, já que existem tantos projetos baseados em C ++ por aí. De motores de jogos de ponta a bibliotecas robustas de aprendizado de máquina. Escolhendo qualquer um deles e colando em cima dele, uma interface PureScript oferece algumas vantagens distintas.

Escolher o Panda3D, acredito, é o caminho certo a seguir. Ele não tenta controlar o fluxo de trabalho – do conceito ao executável – tornando-o perfeito para mim enquanto gosto de percorrer caminhos não explorados, vendo o que é possível, como fiz com os projetos Gifcurry e Movie Monad . Originalmente, eu queria usar Godot, mas precisava que fosse algo que eu conectasse em vez de um ambiente que você ampliava por dentro. Se você conhece um jogo que controla main e usa Godot com apenas C ++, por favor me avise. De qualquer forma, é fácil ignorar o Panda3D como um não iniciado, se você visualizar apenas o site, mas se você aprofundar um pouco mais – você encontrará alguns ótimos projetos usando-o como o abaixo.

Espero que outros usem a PSN cada vez mais. Estou muito animado para transformar o conceito por trás do Lambda Lantern em um jogo completo, mas estou ainda mais animado em usá-lo para divulgar e construir o ecossistema em torno da PSN. Na minha opinião, ser capaz de segmentar tanto a web (JavaScript) quanto a área de trabalho (C ++) com facilidade – usando apenas o PureScript – é um sonho que se torna realidade. ?